From 255c18cf777f5c76084f7cb2abf706763d5d4d5a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 4 Nov 2026 22:49:50 -0500 Subject: [PATCH 01/82] feat(desktop-gpui): native desktop client that starts and connects to the server A native client lets us replace Electron without shipping Chromium or a Node runtime: the app launches the server with the user's Node, the same bootstrap handshake Electron uses, so no pairing is needed. Signed-off-by: Yordis Prieto --- .gitignore | 1 + apps/desktop-gpui/Cargo.lock | 6755 +++++++++++++++++++++++++++ apps/desktop-gpui/Cargo.toml | 36 + apps/desktop-gpui/README.md | 47 + apps/desktop-gpui/package.json | 10 + apps/desktop-gpui/src/auth.rs | 183 + apps/desktop-gpui/src/config.rs | 86 + apps/desktop-gpui/src/connection.rs | 170 + apps/desktop-gpui/src/main.rs | 93 + apps/desktop-gpui/src/server.rs | 404 ++ apps/desktop-gpui/src/status.rs | 30 + package.json | 1 + scripts/dev-runner.test.ts | 51 + scripts/dev-runner.ts | 64 +- 14 files changed, 7921 insertions(+), 10 deletions(-) create mode 100644 apps/desktop-gpui/Cargo.lock create mode 100644 apps/desktop-gpui/Cargo.toml create mode 100644 apps/desktop-gpui/README.md create mode 100644 apps/desktop-gpui/package.json create mode 100644 apps/desktop-gpui/src/auth.rs create mode 100644 apps/desktop-gpui/src/config.rs create mode 100644 apps/desktop-gpui/src/connection.rs create mode 100644 apps/desktop-gpui/src/main.rs create mode 100644 apps/desktop-gpui/src/server.rs create mode 100644 apps/desktop-gpui/src/status.rs diff --git a/.gitignore b/.gitignore index 05ac43c32f1e..dc9af11ed7a1 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,7 @@ artifacts/mobile-e2e/ artifacts/mobile-behavior/ .github/pr-assets/ native/**/target/ +apps/desktop-gpui/target node_modules/ .alchemy/ *.log diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock new file mode 100644 index 000000000000..7691fef2bbe9 --- /dev/null +++ b/apps/desktop-gpui/Cargo.lock @@ -0,0 +1,6755 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "enumn", + "uuid", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_windows" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1" +dependencies = [ + "accesskit", + "accesskit_consumer", + "hashbrown 0.16.1", + "static_assertions", + "windows 0.62.2", + "windows-core 0.62.2", +] + +[[package]] +name = "addr2line" +version = "0.27.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e567177890eb1617b1f774005b66b26b2377afd138a2ca37aae7d8f0c81429d4" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", + "zeroize", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arbitrary" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "ashpd" +version = "0.13.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb8421aaa9644a5faf26735f258b669b15f063313ef8f8e2bdb28912a1a6f111" +dependencies = [ + "enumflags2", + "futures-util", + "getrandom 0.4.3", + "serde", + "zbus", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f426a31126c735126297bc5ea7a521263c9faff4f63ca415267ee625651c4f37" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-fs" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8034a681df4aed8b8edbd7fbe472401ecf009251c8b40556b304567052e294c5" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0876700b0d007ad25d5508865a4259c3af9af6d296bf663a0d586d0bc3369f6f" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-net" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b948000fad4873c1c9339d60f2623323a0cfd3816e5181033c6a5cb68b2accf7" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey 0.1.1", + "rayon", + "thiserror", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "aws-lc-rs" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "048006790404103c488e6c5616b3e7e702a71292aadda08e8f7698a9e0d2eb53" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bfbe32ac36b3c428696ce315464181bc4029d689fd58e3f2a4e58c252242f52" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "backtrace" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f4220217f3288537b83684130f42fad43320b1c3b805f1910d683d52ded24f" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide 0.9.1", + "object", + "rustc-demangle", + "windows-link 0.2.1", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + +[[package]] +name = "bit-set" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.3", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.5", +] + +[[package]] +name = "blocking" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" +dependencies = [ + "bitflags 2.13.2", + "polling", + "rustix", + "tracing", +] + +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + +[[package]] +name = "cbindgen" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadd868a2ce9ca38de7eeafdcec9c7065ef89b42b32f0839278d55f35c54d1ff" +dependencies = [ + "heck 0.4.1", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 2.0.119", + "tempfile", + "toml 0.8.23", +] + +[[package]] +name = "cc" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link 0.2.1", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", + "zeroize", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "collections" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "gpui_util", + "indexmap", + "rustc-hash 2.1.3", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "combine" +version = "4.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" +dependencies = [ + "bytes", + "memchr", +] + +[[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 = "console_error_panic_hook" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "libc", +] + +[[package]] +name = "core-text" +version = "21.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" +dependencies = [ + "core-foundation", + "core-graphics", + "foreign-types", + "libc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cosmic-text" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be17b688510d934ce13f48a2beba700e11583e281e0fda99c22bb256a14eda73" +dependencies = [ + "bitflags 2.13.2", + "fontdb", + "harfrust", + "linebender_resource_handle", + "log", + "rangemap", + "rustc-hash 2.1.3", + "self_cell", + "skrifa 0.40.0", + "smol_str", + "swash", + "sys-locale", + "unicode-bidi", + "unicode-linebreak", + "unicode-script", + "unicode-segmentation", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "ctor" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + +[[package]] +name = "ctutils" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" +dependencies = [ + "cmov", +] + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + +[[package]] +name = "deranged" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "derive_refineable" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", + "ctutils", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dwrote" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b35532432acc8b19ceed096e35dfa088d3ea037fe4f3c085f1f97f33b4d02" +dependencies = [ + "lazy_static", + "libc", + "winapi", + "wio", +] + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" + +[[package]] +name = "embed-resource" +version = "3.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.2.0+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "enumn" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[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 = "etagere" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc89bf99e5dc15954a60f707c1e09d7540e5cd9af85fa75caa0b510bc08c5342" +dependencies = [ + "euclid", + "svg_fmt", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "event-listener" +version = "5.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" +dependencies = [ + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "exr" +version = "1.74.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide 0.8.9", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[package]] +name = "float-cmp" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.9", +] + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "font-types" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree 0.20.0", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "freetype-sys" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7edc5b9669349acfda99533e9e0bcf26a51862ab43b08ee7745c55d28eb134" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-executor" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gimli" +version = "0.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1033caf0b349c518623b5396bfb2cf0bddf44f0306d543a250e5743297aafd10" + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "263218b0ba2b0715c3370fb04674f5f8aa331594b521c94ff0a8e4a8472d1386" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror", + "windows 0.62.2", +] + +[[package]] +name = "gpui" +version = "0.2.2" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "accesskit", + "anyhow", + "async-channel", + "async-task", + "backtrace", + "bitflags 2.13.2", + "chrono", + "collections", + "derive_more", + "embed-resource", + "etagere", + "futures", + "getrandom 0.3.4", + "gpui_macros", + "gpui_shared_string", + "gpui_util", + "heapless", + "http_client", + "icu_segmenter", + "image", + "inventory", + "itertools", + "libc", + "log", + "lyon", + "num_cpus", + "objc2-core-foundation", + "objc2-core-video", + "parking", + "parking_lot", + "pin-project", + "pollster", + "profiling", + "rand 0.9.5", + "raw-window-handle", + "refineable", + "regex", + "resvg", + "scheduler", + "schemars", + "seahash", + "serde", + "serde_json", + "slotmap", + "smallvec", + "spin 0.10.1", + "strum", + "sum_tree", + "taffy", + "thiserror", + "ttf-parser", + "unicode-segmentation", + "url", + "usvg", + "uuid", + "waker-fn", + "windows 0.62.2", + "zed-scap", +] + +[[package]] +name = "gpui_apple" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "anyhow", + "async-task", + "block2 0.6.2", + "cbindgen", + "collections", + "core-foundation", + "core-foundation-sys", + "core-graphics", + "core-text", + "dispatch2", + "etagere", + "futures", + "gpui", + "gpui_util", + "image", + "libc", + "log", + "mach2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-text", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-security", + "parking_lot", + "pathfinder_geometry", + "smallvec", + "thiserror", + "zed-font-kit", +] + +[[package]] +name = "gpui_ios" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "anyhow", + "block2 0.6.2", + "futures", + "gpui", + "gpui_apple", + "log", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-ui-kit", + "parking_lot", + "raw-window-handle", + "uuid", +] + +[[package]] +name = "gpui_linux" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "accesskit", + "anyhow", + "bytemuck", + "calloop", + "collections", + "futures", + "gpui", + "gpui_util", + "http_client", + "libc", + "log", + "notify-rust", + "oo7", + "parking_lot", + "raw-window-handle", + "smallvec", + "smol", + "strum", + "url", + "uuid", +] + +[[package]] +name = "gpui_macos" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "accesskit", + "accesskit_macos", + "anyhow", + "block2 0.6.2", + "collections", + "core-foundation", + "core-foundation-sys", + "core-graphics", + "ctor", + "dispatch2", + "foreign-types", + "futures", + "gpui", + "gpui_apple", + "gpui_util", + "image", + "itertools", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-graphics", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", + "parking_lot", + "raw-window-handle", + "scheduler", + "semver", + "smallvec", + "strum", + "uuid", +] + +[[package]] +name = "gpui_macros" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui_platform" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "anyhow", + "console_error_panic_hook", + "gpui", + "gpui_ios", + "gpui_linux", + "gpui_macos", + "gpui_web", + "gpui_windows", +] + +[[package]] +name = "gpui_shared_string" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "schemars", + "serde", + "smol_str", +] + +[[package]] +name = "gpui_util" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "anyhow", + "log", + "which", +] + +[[package]] +name = "gpui_web" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "anyhow", + "async-channel", + "console_error_panic_hook", + "futures", + "gpui", + "gpui_wgpu", + "http_client", + "js-sys", + "log", + "parking_lot", + "raw-window-handle", + "scheduler", + "smallvec", + "unicode-properties", + "unicode-script", + "unicode-segmentation", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm_thread", + "web-sys", + "web-time", +] + +[[package]] +name = "gpui_wgpu" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "anyhow", + "bytemuck", + "collections", + "cosmic-text", + "etagere", + "gpui", + "gpui_util", + "itertools", + "log", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "swash", + "unicode-bidi", + "unicode-segmentation", + "web-sys", + "wgpu", +] + +[[package]] +name = "gpui_windows" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "accesskit", + "accesskit_windows", + "anyhow", + "collections", + "dunce", + "etagere", + "futures", + "gpui", + "gpui_util", + "image", + "itertools", + "log", + "parking_lot", + "rand 0.9.5", + "raw-window-handle", + "scheduler", + "smallvec", + "uuid", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-numerics 0.3.1", + "windows-registry", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da2e5ae821f6e96664977bf974d6d6a2d6682f9ccee23e62ec1d134246845f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "core_maths", + "read-fonts 0.37.0", + "smallvec", +] + +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "stable_deref_trait", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "http_client" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "anyhow", + "bytes", + "futures", + "http", + "http-body", + "parking_lot", + "serde", + "serde_json", + "serde_urlencoded", + "url", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "406e9ae5b95d8aa37db6293405931b766a6172d1848daee22d70eb44aa78a43c" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3632ebbd0c5c3637ba1a40bdf8288f2cb9d2268e73441a69d5fc73d7cfbaa87c" + +[[package]] +name = "icu_normalizer" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" + +[[package]] +name = "icu_properties" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" + +[[package]] +name = "icu_provider" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61b195dc605f81aa181bec9b3bd14e26cc71b903e4644f6af578ca9772c7a710" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913a7d294ad056474749152107309fdee3ec17d2482d1ab70fdc61a81c64b9a3" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png 0.18.1", + "qoi", + "ravif", + "rayon", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imagesize" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" + +[[package]] +name = "imgref" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "inventory" +version = "0.3.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c" +dependencies = [ + "rustversion", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" +dependencies = [ + "libc", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "link-section" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" + +[[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 = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[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.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + +[[package]] +name = "lyon" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0578bdecb7d6d88987b8b2b1e3a4e2f81df9d0ece1078623324a567904e7b7" +dependencies = [ + "lyon_algorithms", + "lyon_tessellation", +] + +[[package]] +name = "lyon_algorithms" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3a6b9d4e8b48acd531fbf1241ad9b270df6e60b6b92bdea1469f4f40899d805" +dependencies = [ + "lyon_path", + "num-traits", +] + +[[package]] +name = "lyon_geom" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d82dd26aa4dc0b5b70b9115de59824842bcb43cf4a28e1b1e6d0ad148c93f93e" +dependencies = [ + "arrayvec", + "euclid", + "num-traits", +] + +[[package]] +name = "lyon_path" +version = "1.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c463f9c428b7fc5ec885dcd39ce4aa61e29111d0e33483f6f98c74e89d8621e" +dependencies = [ + "lyon_geom", + "num-traits", +] + +[[package]] +name = "lyon_tessellation" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd4872650e70dc907dd4f7ebc449d7d657a0d2658e423b2e724970870bbbf75b" +dependencies = [ + "float_next_after", + "lyon_path", + "num-traits", +] + +[[package]] +name = "mac-notification-sys" +version = "0.6.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca" +dependencies = [ + "cc", + "log", + "objc2 0.6.5", + "objc2-foundation 0.3.2", + "time", + "uuid", +] + +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "macro_rules_attribute" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" +dependencies = [ + "macro_rules_attribute-proc_macro", + "pastey 0.2.3", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest 0.10.7", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157" +dependencies = [ + "libc", +] + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "30.0.0" +source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.0" +source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "notify-rust" +version = "4.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4587364a9a0074333429b3df75a30a205340c56a536ca3eb6ca0e59b87bbf8af" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f9a86e097b0d187ad0e65667c2f58b9254671e86e7dbb78036b16692eae099" +dependencies = [ + "libm", + "num-integer", + "num-iter", + "num-traits", + "once_cell", + "rand 0.9.5", + "serde", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-uniform-type-identifiers", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "dispatch2", + "libc", + "objc2 0.6.5", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.2", + "dispatch2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-core-video" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-io-surface", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "libc", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "dispatch2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-security" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" +dependencies = [ + "objc2-core-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7902ac02859fc1f7045f8b598c63f1ae0cc7efeaa06a9bc9f3d9a3c955974fa4" +dependencies = [ + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "object" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd229a0361b9d0d4396176e02d65897f487eebeab7caa6d443855ee152ca0b9c" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oo7" +version = "0.6.0" +source = "git+https://github.com/zed-industries/oo7?rev=731c1215edb6905876c6137ab300428c7b8d7c13#731c1215edb6905876c6137ab300428c7b8d7c13" +dependencies = [ + "aes", + "ashpd", + "async-fs", + "async-io", + "async-lock", + "blocking", + "cbc", + "cipher", + "digest 0.10.7", + "endi", + "futures-lite", + "futures-util", + "getrandom 0.4.3", + "hkdf", + "hmac 0.12.1", + "md-5", + "num", + "num-bigint-dig", + "pbkdf2", + "serde", + "serde_bytes", + "sha2 0.10.9", + "subtle", + "zbus", + "zbus_macros", + "zeroize", + "zvariant", +] + +[[package]] +name = "openssl-probe" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e036b411754132fd6b74e94b74e9a13c5cf606fa93d1885c35860d3060820b1" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "5.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f59006ce1e204c30956638535b74d317bca99477b9356dfaec54c37f6f600ab" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[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 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pastey" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" + +[[package]] +name = "pathfinder_geometry" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b7e7b4ea703700ce73ebf128e1450eb69c3a8329199ffbfb9b2a0418e5ad3" +dependencies = [ + "log", + "pathfinder_simd", +] + +[[package]] +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +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 = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[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 = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.18+spec-1.1.0", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash 2.1.3", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash 2.1.3", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "rangemap" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d" + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.5", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "read-fonts" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +dependencies = [ + "bytemuck", + "core_maths", + "font-types 0.11.3", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.7", + "once_cell", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "redox_users" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" +dependencies = [ + "libredox", + "thiserror", +] + +[[package]] +name = "ref-cast" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "refineable" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "derive_refineable", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[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 = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "resvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" +dependencies = [ + "gif", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes", + "tiny-skia", + "usvg", + "zune-jpeg", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + +[[package]] +name = "roxmltree" +version = "0.21.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9da7f59328b09405b181fee1018925fad50f157afb43c0c0f5c522c256dd5907" +dependencies = [ + "memchr", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustybuzz" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c7c96f8a08ee34eff8857b11b49b07d71d1c3f4e88f8a88d4c9e9f90b1702" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "core_maths", + "log", + "smallvec", + "ttf-parser", + "unicode-bidi-mirroring", + "unicode-ccc", + "unicode-properties", + "unicode-script", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cb23c424d8ee841bb4d813677ca237e2a610067d594fec130f87d6093495ab5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scheduler" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "async-task", + "backtrace", + "chrono", + "flume", + "futures", + "parking_lot", + "rand 0.9.5", + "wasm_thread", + "web-time", +] + +[[package]] +name = "schemars" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" +dependencies = [ + "dyn-clone", + "indexmap", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.9", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.2", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "sha1_smol" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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 = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simplecss" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" +dependencies = [ + "log", +] + +[[package]] +name = "siphasher" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skrifa" +version = "0.40.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +dependencies = [ + "bytemuck", + "read-fonts 0.37.0", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8" + +[[package]] +name = "smol" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a33bd3e260892199c3ccfc487c88b2da2265080acb316cd920da72fdfd7c599f" +dependencies = [ + "async-channel", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite", +] + +[[package]] +name = "smol_str" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb7109d23f319619d4cc0de5464613c8b7b1a09db2774a58101459cf6be2f10" +dependencies = [ + "serde_core", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +dependencies = [ + "float-cmp", +] + +[[package]] +name = "strum" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "sum_tree" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "heapless", + "rayon", + "tracing", + "ztracing", +] + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "svgtypes" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f12b12995e3638c70618c1facb1ce65302f6a6f706b675f07c58a5d7391120e" +dependencies = [ + "kurbo", + "siphasher", +] + +[[package]] +name = "swash" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" +dependencies = [ + "skrifa 0.44.0", + "yazi", + "zeno", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "libc", +] + +[[package]] +name = "sysinfo" +version = "0.31.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "355dbe4f8799b304b05e1b0f05fc59b2a18d36645cf169607da45bde2f69a1be" +dependencies = [ + "core-foundation-sys", + "libc", + "memchr", + "ntapi", + "rayon", + "windows 0.57.0", +] + +[[package]] +name = "t3-desktop-gpui" +version = "0.1.0" +dependencies = [ + "async-channel", + "futures-util", + "getrandom 0.4.3", + "gpui", + "gpui_platform", + "hex", + "hmac 0.13.0", + "libc", + "reqwest", + "serde", + "serde_json", + "sha2 0.11.1", + "tokio", + "tokio-tungstenite", +] + +[[package]] +name = "taffy" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c8480887e8803986d96a7173729565520d4bbb721ad6b7ad6941a22897207aa" +dependencies = [ + "serde", + "slotmap", + "smallvec", +] + +[[package]] +name = "tauri-winrt-notification" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36" +dependencies = [ + "thiserror", + "windows 0.62.2", + "windows-version", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "png 0.17.16", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tinystr" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17a073bfed563fa236697a068031408a93cd9522e08abf9933ead3e73411bd71" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", +] + +[[package]] +name = "toml" +version = "1.2.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5ffcdf8f50436dbbfd675c1fe1b8e0854f42b7defea8584bdebe1aee900e561" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned 1.1.2", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.5", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", + "toml_write", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.18+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec6e78bd70db9420d8d212fa9dc97f116448a1ceff5d00eabb991d407e21396" +dependencies = [ + "indexmap", + "toml_datetime 1.1.2+spec-1.1.0", + "toml_parser", + "winnow 1.0.5", +] + +[[package]] +name = "toml_parser" +version = "1.1.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" +dependencies = [ + "winnow 1.0.5", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "toml_writer" +version = "1.2.0+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.2", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "tungstenite" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48ac77174b19c110a50ab2128b24215ac9cb40e0e12e093fb602d175c569d22" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.10.3", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-bidi-mirroring" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dfa6e8c60bb66d49db113e0125ee8711b7647b5579dc7f5f19c42357ed039fe" + +[[package]] +name = "unicode-ccc" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-properties" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" + +[[package]] +name = "unicode-script" +version = "0.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa04c955db05429d464c74d495c1ee677e1866e783a4c3747f9c0cef7756cabf" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-vo" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1d386ff53b415b7fe27b50bb44679e2cc4660272694b7b6f3326d8480823a94" + +[[package]] +name = "unicode-width" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "usvg" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e419dff010bb12512b0ae9e3d2f318dfbdf0167fde7eb05465134d4e8756076f" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb", + "imagesize", + "kurbo", + "log", + "pico-args", + "roxmltree 0.21.2", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + +[[package]] +name = "utf8_iter" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] + +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + +[[package]] +name = "uuid" +version = "1.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "sha1_smol", + "wasm-bindgen", +] + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +dependencies = [ + "cfg-if", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.130" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm_thread" +version = "0.3.3" +source = "git+https://github.com/zed-industries/wasm_thread?rev=0cf96c7708dfb97ccf3da50347e25edcf75d6937#0cf96c7708dfb97ccf3da50347e25edcf75d6937" +dependencies = [ + "futures", + "js-sys", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "30.0.0" +source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-sync", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.0" +source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "num-traits", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "scopeguard", + "smallvec", + "thiserror", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-linux-android-bsd", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-sync", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.0" +source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.0" +source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-linux-android-bsd" +version = "30.0.0" +source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-wasm" +version = "30.0.0" +source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows" +version = "30.0.0" +source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.0" +source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "ordered-float", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-sync", + "wgpu-types", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-result 0.4.1", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.0" +source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-sync" +version = "30.0.0" +source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +dependencies = [ + "cfg-if", + "lock_api", + "once_cell", + "parking_lot", + "portable-atomic", + "portable-atomic-util", +] + +[[package]] +name = "wgpu-types" +version = "30.0.0" +source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "macro_rules_attribute", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[package]] +name = "which" +version = "8.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bae2f2b2b816647a1cab1acc91f5bd20812d53cb344382635ec2181940c8034f" +dependencies = [ + "libc", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" +dependencies = [ + "windows-core 0.57.0", + "windows-targets", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections 0.2.0", + "windows-core 0.61.2", + "windows-future 0.2.1", + "windows-link 0.1.3", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", +] + +[[package]] +name = "windows-capture" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" +dependencies = [ + "parking_lot", + "rayon", + "thiserror", + "windows 0.61.3", + "windows-future 0.2.1", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + +[[package]] +name = "windows-core" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ed2439a290666cd67ecce2b0ffaad89c2a56b976b736e6ece670297897832d" +dependencies = [ + "windows-implement 0.57.0", + "windows-interface 0.57.0", + "windows-result 0.1.2", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", +] + +[[package]] +name = "windows-implement" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.57.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[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-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[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 = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c3b93199df0ab874cdf0c6a94f0a0dc3423fe41f1cafb62b8bd7b1deb277aef" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys 0.61.2", +] + +[[package]] +name = "wio" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d129932f4644ac2396cb456385cbf9e63b5b30c6e8dc4820bdca4eb082037a5" +dependencies = [ + "winapi", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "xcb" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6c2ad15e0e922856ee89afe862b8992334bbe7953adad56cd1199358cb30566" +dependencies = [ + "bitflags 2.13.2", + "libc", + "quick-xml", + "x11", +] + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + +[[package]] +name = "xmlwriter" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yazi" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac366ea532fa8ee6a992cec9dea525b1dc284e69702f36c2ab5fbebadc81ec86" + +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.5", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812a12be4ead7a59e18ea8f047d08add0066b73bc62fc1467066a2472ae088dd" +dependencies = [ + "serde", + "winnow 1.0.5", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "939f186d9ecebe044315a5cb6d6f78568bbc21152adc954d5a0c2f58bdbc9920" +dependencies = [ + "serde", +] + +[[package]] +name = "zed-font-kit" +version = "0.14.1-zed" +source = "git+https://github.com/zed-industries/font-kit?rev=94b0f28166665e8fd2f53ff6d268a14955c82269#94b0f28166665e8fd2f53ff6d268a14955c82269" +dependencies = [ + "bitflags 2.13.2", + "byteorder", + "core-foundation", + "core-graphics", + "core-text", + "dirs", + "dwrote", + "float-ord", + "freetype-sys", + "lazy_static", + "libc", + "log", + "pathfinder_geometry", + "pathfinder_simd", + "walkdir", + "winapi", + "yeslogic-fontconfig-sys", +] + +[[package]] +name = "zed-scap" +version = "0.0.8-zed" +source = "git+https://github.com/zed-industries/scap?rev=b5c86ec58dd4b6b7732bf6ac8be03d6b1f190a82#b5c86ec58dd4b6b7732bf6ac8be03d6b1f190a82" +dependencies = [ + "anyhow", + "log", + "rand 0.8.8", + "sysinfo", + "windows 0.61.3", + "windows-capture", + "x11", + "xcb", +] + +[[package]] +name = "zeno" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "ztracing" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +dependencies = [ + "tracing", + "ztracing_macro", +] + +[[package]] +name = "ztracing_macro" +version = "0.1.0" +source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80bf6d6cd6a4b601acb64cfb8284cdc643fcfa75ef26ae3c46ec8aa4e8a1fbb7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "serde_bytes", + "winnow 1.0.5", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef2672456ba1cb54b5eaec136e63135b3c7495fb938e53b28a62e633496d486d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow 1.0.5", +] diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml new file mode 100644 index 000000000000..cb23e47de817 --- /dev/null +++ b/apps/desktop-gpui/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "t3-desktop-gpui" +version = "0.1.0" +edition = "2024" +license = "MIT" +publish = false + +[dependencies] +async-channel = "2.5.0" +futures-util = "0.3.34" +getrandom = "0.4.3" +gpui = { git = "https://github.com/zed-industries/zed", rev = "c7bea343838f42b4c14151393e41f0bbe033c95e" } +gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "c7bea343838f42b4c14151393e41f0bbe033c95e", features = [ + "font-kit", +] } +hex = "0.4.3" +hmac = "0.13.0" +libc = "0.2.191" +reqwest = { version = "0.13.5", default-features = false, features = ["form", "json", "rustls"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.154" +sha2 = "0.11.1" +tokio = { version = "1.54.1", features = [ + "rt-multi-thread", + "process", + "time", + "sync", + "io-util", + "macros", +] } +tokio-tungstenite = "0.30.0" + +[profile.release] +codegen-units = 1 +lto = "thin" +strip = true diff --git a/apps/desktop-gpui/README.md b/apps/desktop-gpui/README.md new file mode 100644 index 000000000000..f8d890596ecd --- /dev/null +++ b/apps/desktop-gpui/README.md @@ -0,0 +1,47 @@ +# T3 Code desktop (GPUI) + +Native desktop client written in Rust with [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui). It is meant to replace `apps/desktop` (Electron) over time and never embeds Node or Chromium. It starts the T3 server as a child process, authenticates without pairing, and opens the RPC WebSocket. + +Status: connection shell. The window shows the connection state ("Starting server...", "Connecting...", "Connected to ", or the error). There are no RPC methods or screens yet. + +## Run + +From the repo root: + +```sh +vp run dev:desktop-gpui +``` + +This works like `vp run dev:desktop`: one command. The dev runner derives the server port and the per-worktree `.t3` home (never `~/.t3`), then runs `cargo run` here. No Vite or web server starts. Pass `--home-dir` to use another T3 home. + +The app reads these variables, which the dev runner sets. Without them it shows "Not connected" and starts nothing. + +| Variable | Meaning | +| ---------------------------- | ------------------------------------------------------------------- | +| `T3CODE_GPUI_SERVER_PORT` | Loopback port for the server. | +| `T3CODE_GPUI_HOME` | T3 home passed to the server. | +| `T3CODE_GPUI_SERVER_COMMAND` | Program that starts the server (`node` in dev, `t3` once packaged). | +| `T3CODE_GPUI_SERVER_ENTRY` | Optional first argument, the server entry script in dev. | + +Other scripts: + +```sh +vp run --filter @t3tools/desktop-gpui build # cargo build --release +vp run --filter @t3tools/desktop-gpui test # cargo test +``` + +## How it connects + +1. Spawns ` [entry] --bootstrap-fd 3` and writes one JSON bootstrap line (`packages/contracts/src/desktopBootstrap.ts`) to a pipe dup'd onto fd 3, as Electron does. The secret is random per launch. +2. Polls `GET /.well-known/t3/environment` until the server is ready. +3. Exchanges the HMAC bootstrap token for a bearer session, mints a WebSocket ticket, and opens `/ws`. +4. Sends an RPC `Ping` every 5 seconds and reconnects with backoff after 15 seconds of silence. + +On quit the app sends SIGTERM to the server it spawned and SIGKILL after 5 seconds. If the app is killed, the server notices its parent changed within about 2 seconds and shuts itself down. + +## Notes + +- `gpui` and `gpui_platform` are pinned to one `zed-industries/zed` commit in `Cargo.toml`. Bump both together. +- macOS needs the Xcode Metal toolchain (`xcodebuild -downloadComponent MetalToolchain`) and the `font-kit` feature, which is enabled. +- Server spawning uses a unix pipe for fd 3, so it is macOS and Linux only for now. +- CI does not build this crate yet. GPUI needs a macOS runner or Linux system libraries (wayland, x11, xkbcommon, vulkan). diff --git a/apps/desktop-gpui/package.json b/apps/desktop-gpui/package.json new file mode 100644 index 000000000000..eb7b4dec7dee --- /dev/null +++ b/apps/desktop-gpui/package.json @@ -0,0 +1,10 @@ +{ + "name": "@t3tools/desktop-gpui", + "version": "0.0.0", + "private": true, + "scripts": { + "dev": "cargo run --locked", + "build": "cargo build --locked --release", + "test": "cargo test --locked" + } +} diff --git a/apps/desktop-gpui/src/auth.rs b/apps/desktop-gpui/src/auth.rs new file mode 100644 index 000000000000..618ab7ffca6f --- /dev/null +++ b/apps/desktop-gpui/src/auth.rs @@ -0,0 +1,183 @@ +//! Desktop bootstrap credential and the bearer/WebSocket-ticket exchange. +//! Mirrors packages/shared/src/desktopBootstrapToken.ts and +//! packages/client-runtime/src/authorization/remote.ts. + +use hmac::{Hmac, KeyInit, Mac}; +use serde::Deserialize; +use sha2::Sha256; +use std::fmt::Write; +use std::time::{SystemTime, UNIX_EPOCH}; + +const TOKEN_WINDOW_MS: u64 = 12 * 60 * 60 * 1000; +const GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:token-exchange"; +const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token"; +const BOOTSTRAP_TOKEN_TYPE: &str = "urn:t3:params:oauth:token-type:environment-bootstrap"; +const CLIENT_LABEL: &str = "T3 Code Desktop (GPUI)"; + +pub fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.as_millis() as u64) + .unwrap_or(0) +} + +/// 256 random bits, hex encoded. Only this process and its server ever see it. +pub fn generate_secret() -> Result { + let mut bytes = [0u8; 32]; + getrandom::fill(&mut bytes).map_err(|error| format!("could not read random bytes: {error}"))?; + Ok(hex::encode(bytes)) +} + +/// HMAC-SHA256 keyed by the secret over `t3-desktop-bootstrap:`, hex encoded. +pub fn bootstrap_token(secret: &str, now_ms: u64) -> String { + let window = now_ms / TOKEN_WINDOW_MS; + let mut mac = + Hmac::::new_from_slice(secret.as_bytes()).expect("HMAC takes any key length"); + let mut message = String::new(); + write!(message, "t3-desktop-bootstrap:{window}").expect("writing to a String cannot fail"); + mac.update(message.as_bytes()); + hex::encode(mac.finalize().into_bytes()) +} + +/// Loopback only: an ambient HTTP(S)_PROXY must never capture traffic to the local server. +pub fn http_client() -> reqwest::Client { + reqwest::Client::builder() + .no_proxy() + .build() + .expect("the HTTP client configuration is static") +} + +#[derive(Debug)] +pub enum AuthError { + /// The server refused the credential, so a retry needs a fresh one. + Rejected(u16), + Failed(String), +} + +impl std::fmt::Display for AuthError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Rejected(status) => write!(f, "server rejected the credential (HTTP {status})"), + Self::Failed(message) => f.write_str(message), + } + } +} + +impl From for AuthError { + fn from(error: reqwest::Error) -> Self { + Self::Failed(error.without_url().to_string()) + } +} + +#[derive(Deserialize)] +struct AccessTokenResult { + access_token: String, +} + +#[derive(Deserialize)] +struct TicketResult { + ticket: String, +} + +/// RFC 8693 exchange of the desktop bootstrap token for a bearer session. +pub async fn exchange_bearer( + client: &reqwest::Client, + http_base: &str, + secret: &str, +) -> Result { + let credential = bootstrap_token(secret, now_ms()); + let response = client + .post(format!("{http_base}/oauth/token")) + .form(&[ + ("grant_type", GRANT_TYPE), + ("subject_token", credential.as_str()), + ("subject_token_type", BOOTSTRAP_TOKEN_TYPE), + ("requested_token_type", ACCESS_TOKEN_TYPE), + ("client_label", CLIENT_LABEL), + ("client_device_type", "desktop"), + ]) + .send() + .await?; + let status = response.status(); + if status.is_client_error() { + return Err(AuthError::Rejected(status.as_u16())); + } + let result: AccessTokenResult = response.error_for_status()?.json().await?; + Ok(result.access_token) +} + +/// Mints an RPC WebSocket ticket. A server without audience routes answers 404 +/// for `/:use`, and its bare-path tickets are valid for RPC. +pub async fn issue_ws_ticket( + client: &reqwest::Client, + http_base: &str, + bearer: &str, +) -> Result { + let mint = |path: &'static str| async move { + client + .post(format!("{http_base}{path}")) + .bearer_auth(bearer) + .send() + .await + }; + let mut response = mint("/api/auth/websocket-ticket/rpc").await?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + response = mint("/api/auth/websocket-ticket").await?; + } + let status = response.status(); + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + return Err(AuthError::Rejected(status.as_u16())); + } + let result: TicketResult = response.error_for_status()?.json().await?; + Ok(result.ticket) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Computed with currentDesktopBootstrapToken from packages/shared. + #[test] + fn matches_the_typescript_token() { + let vectors = [ + ( + "test-secret", + 0, + "29cdd8a2256df92036e83f50c98bafcefa22fbf04e12d040cd53bd1680f62e4c", + ), + ( + "test-secret", + 1_700_000_000_000, + "fdcc803bed0e5fcbde01221220f9abcd496feaa9b06965a0761d6927d9b5c38e", + ), + ( + "another-secret-value", + 86_400_000, + "7e1f8f7f6a6ecc013ea23398711d2b7c762684f88e9f688f8bf4e5155da281ae", + ), + ]; + for (secret, now, expected) in vectors { + assert_eq!(bootstrap_token(secret, now), expected); + } + } + + #[test] + fn token_is_stable_inside_a_window_and_changes_across_it() { + let start = 5 * TOKEN_WINDOW_MS; + assert_eq!( + bootstrap_token("s", start), + bootstrap_token("s", start + TOKEN_WINDOW_MS - 1) + ); + assert_ne!( + bootstrap_token("s", start), + bootstrap_token("s", start + TOKEN_WINDOW_MS) + ); + } + + #[test] + fn secrets_are_random_hex() { + let first = generate_secret().expect("secret"); + assert_eq!(first.len(), 64); + assert_ne!(first, generate_secret().expect("secret")); + } +} diff --git a/apps/desktop-gpui/src/config.rs b/apps/desktop-gpui/src/config.rs new file mode 100644 index 000000000000..1ff8afe8ce68 --- /dev/null +++ b/apps/desktop-gpui/src/config.rs @@ -0,0 +1,86 @@ +//! Launch settings handed over by the dev runner (or, later, a packaged launcher). + +pub const ENV_PORT: &str = "T3CODE_GPUI_SERVER_PORT"; +pub const ENV_HOME: &str = "T3CODE_GPUI_HOME"; +pub const ENV_COMMAND: &str = "T3CODE_GPUI_SERVER_COMMAND"; +pub const ENV_ENTRY: &str = "T3CODE_GPUI_SERVER_ENTRY"; + +/// How to start the server. The command is data: dev runs `node `, a +/// packaged build runs `t3` from PATH with no entry. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ServerCommand { + pub program: String, + pub args: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Config { + pub port: u16, + pub t3_home: String, + pub command: ServerCommand, +} + +impl Config { + pub fn http_base(&self) -> String { + format!("http://127.0.0.1:{}", self.port) + } + + /// `None` when the launcher did not ask this app to manage a server. + pub fn from_env() -> Option { + Self::from_lookup(|name| std::env::var(name).ok()) + } + + fn from_lookup(get: impl Fn(&str) -> Option) -> Option { + let non_empty = |name| get(name).filter(|value| !value.trim().is_empty()); + let port = non_empty(ENV_PORT)?.trim().parse().ok()?; + let t3_home = non_empty(ENV_HOME)?; + let program = non_empty(ENV_COMMAND)?; + let args = non_empty(ENV_ENTRY).into_iter().collect(); + Some(Self { + port, + t3_home, + command: ServerCommand { program, args }, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lookup(pairs: &'static [(&'static str, &'static str)]) -> impl Fn(&str) -> Option { + move |name| { + pairs + .iter() + .find(|(key, _)| *key == name) + .map(|(_, value)| value.to_string()) + } + } + + #[test] + fn reads_all_settings() { + let config = Config::from_lookup(lookup(&[ + (ENV_PORT, "13773"), + (ENV_HOME, "/work/.t3"), + (ENV_COMMAND, "node"), + (ENV_ENTRY, "/work/apps/server/src/bin.ts"), + ])) + .expect("config"); + assert_eq!(config.port, 13773); + assert_eq!(config.command.args, vec!["/work/apps/server/src/bin.ts"]); + assert_eq!(config.http_base(), "http://127.0.0.1:13773"); + } + + #[test] + fn entry_is_optional_and_missing_values_disable_the_server() { + let config = Config::from_lookup(lookup(&[ + (ENV_PORT, "1"), + (ENV_HOME, "/h"), + (ENV_COMMAND, "t3"), + ])) + .expect("config"); + assert!(config.command.args.is_empty()); + assert_eq!(Config::from_lookup(lookup(&[(ENV_PORT, "1")])), None); + assert_eq!(Config::from_lookup(lookup(&[])), None); + } +} diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs new file mode 100644 index 000000000000..3751bb282cb1 --- /dev/null +++ b/apps/desktop-gpui/src/connection.rs @@ -0,0 +1,170 @@ +//! The authenticated RPC WebSocket: connect, keep alive, reconnect. + +use crate::auth::{self, AuthError}; +use crate::config::Config; +use crate::status::{Status, StatusSender, publish}; +use futures_util::{SinkExt, StreamExt}; +use std::time::Duration; +use tokio::time::{Instant, MissedTickBehavior, interval, sleep, sleep_until}; +use tokio_tungstenite::tungstenite::Message; + +/// Effect RPC ping envelope (RpcMessage.constPing) in the JSON serialization. +const PING_FRAME: &str = r#"{"_tag":"Ping"}"#; +const PING_INTERVAL: Duration = Duration::from_secs(5); +// Pings go out every 5 s and a busy server stops answering them, so allow 15 s +// without any frame (packages/client-runtime/src/rpc/protocol.ts PING_TIMEOUT). +const PING_TIMEOUT: Duration = Duration::from_secs(15); +const ORCHESTRATION_PROTOCOL_VERSION: &str = "2"; + +/// Doubling delay between attempts, capped (Electron restarts the backend the same way). +pub struct Backoff { + next: Duration, +} + +impl Backoff { + const INITIAL: Duration = Duration::from_millis(500); + const MAX: Duration = Duration::from_secs(10); + + pub fn new() -> Self { + Self { + next: Self::INITIAL, + } + } + + pub fn reset(&mut self) { + self.next = Self::INITIAL; + } + + pub fn next_delay(&mut self) -> Duration { + let delay = self.next; + self.next = (self.next * 2).min(Self::MAX); + delay + } +} + +fn socket_url(port: u16, ticket: &str) -> Result { + reqwest::Url::parse_with_params( + &format!("ws://127.0.0.1:{port}/ws"), + [ + ("orchestrationProtocol", ORCHESTRATION_PROTOCOL_VERSION), + ("wsTicket", ticket), + ("clientSurface", "desktop"), + ("clientDeviceType", "desktop"), + ("clientAppVersion", env!("CARGO_PKG_VERSION")), + ("connectionMethod", "direct"), + ], + ) + .map_err(|error| error.to_string()) +} + +/// Connects and stays connected until cancelled, reporting through `status`. +pub async fn run(config: &Config, secret: &str, label: &str, status: &StatusSender) { + let client = auth::http_client(); + let http_base = config.http_base(); + let mut bearer: Option = None; + let mut backoff = Backoff::new(); + loop { + publish(status, Status::Connecting); + let failure = match connect(&client, config, &http_base, secret, &mut bearer).await { + Ok(socket) => { + publish(status, Status::Connected(label.to_owned())); + backoff.reset(); + serve(socket).await + } + Err(message) => message, + }; + publish(status, Status::Failed(format!("Reconnecting: {failure}"))); + sleep(backoff.next_delay()).await; + } +} + +type Socket = + tokio_tungstenite::WebSocketStream>; + +async fn connect( + client: &reqwest::Client, + config: &Config, + http_base: &str, + secret: &str, + bearer: &mut Option, +) -> Result { + let token = match bearer { + Some(token) => token.clone(), + None => { + let token = auth::exchange_bearer(client, http_base, secret) + .await + .map_err(|error| error.to_string())?; + *bearer = Some(token.clone()); + token + } + }; + let ticket = match auth::issue_ws_ticket(client, http_base, &token).await { + Ok(ticket) => ticket, + Err(error) => { + if matches!(error, AuthError::Rejected(_)) { + *bearer = None; + } + return Err(error.to_string()); + } + }; + let url = socket_url(config.port, &ticket)?; + let (socket, _) = tokio_tungstenite::connect_async(url.as_str()) + .await + .map_err(|error| error.to_string())?; + Ok(socket) +} + +/// Pings until the socket closes or goes quiet; returns why it ended. +async fn serve(socket: Socket) -> String { + let (mut sink, mut stream) = socket.split(); + let mut ping = interval(PING_INTERVAL); + ping.set_missed_tick_behavior(MissedTickBehavior::Delay); + let mut deadline = Instant::now() + PING_TIMEOUT; + loop { + tokio::select! { + _ = ping.tick() => { + if let Err(error) = sink.send(Message::text(PING_FRAME)).await { + return error.to_string(); + } + } + _ = sleep_until(deadline) => { + return format!("server stopped responding for {}s", PING_TIMEOUT.as_secs()); + } + frame = stream.next() => match frame { + None => return "connection closed".to_owned(), + Some(Err(error)) => return error.to_string(), + Some(Ok(Message::Close(frame))) => { + return frame + .map(|frame| format!("connection closed: {}", frame.reason)) + .unwrap_or_else(|| "connection closed".to_owned()); + } + // No RPC methods yet: any frame, Pong included, proves the server is alive. + Some(Ok(_)) => deadline = Instant::now() + PING_TIMEOUT, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn backoff_doubles_to_a_cap_and_resets() { + let mut backoff = Backoff::new(); + let delays: Vec<_> = (0..7).map(|_| backoff.next_delay().as_millis()).collect(); + assert_eq!(delays, vec![500, 1000, 2000, 4000, 8000, 10_000, 10_000]); + backoff.reset(); + assert_eq!(backoff.next_delay().as_millis(), 500); + } + + #[test] + fn socket_url_carries_protocol_and_escaped_ticket() { + let url = socket_url(13773, "a b&c").expect("url"); + assert_eq!(url.scheme(), "ws"); + assert_eq!(url.path(), "/ws"); + let pairs: Vec<_> = url.query_pairs().into_owned().collect(); + assert!(pairs.contains(&("orchestrationProtocol".into(), "2".into()))); + assert!(pairs.contains(&("wsTicket".into(), "a b&c".into()))); + } +} diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs new file mode 100644 index 000000000000..cce74b27c978 --- /dev/null +++ b/apps/desktop-gpui/src/main.rs @@ -0,0 +1,93 @@ +mod auth; +mod config; +mod connection; +mod server; +mod status; + +use config::Config; +use gpui::{ + App, Bounds, Context, SharedString, TitlebarOptions, Window, WindowBounds, WindowOptions, div, + prelude::*, px, rgb, size, +}; +use gpui_platform::application; +use server::Supervisor; +use status::Status; +use std::rc::Rc; + +struct StatusView { + status: SharedString, +} + +impl Render for StatusView { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .flex() + .size_full() + .justify_center() + .items_center() + .bg(rgb(0x1a1a1a)) + .text_color(rgb(0xe6e6e6)) + .text_xl() + .child(self.status.clone()) + } +} + +fn main() { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .expect("failed to start the async runtime"); + let (status_tx, status_rx) = async_channel::unbounded(); + let supervisor = Config::from_env() + .map(|config| Rc::new(Supervisor::start(runtime.handle(), config, status_tx))); + let initial = if supervisor.is_some() { + Status::Starting + } else { + Status::NotConnected + }; + + application().run(move |cx: &mut App| { + if let Some(supervisor) = supervisor { + // The grace period outlasts gpui's quit timeout, so block here instead of awaiting. + cx.on_app_quit(move |_| { + supervisor.stop(); + async {} + }) + .detach(); + } + let bounds = Bounds::centered(None, size(px(960.), px(640.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + titlebar: Some(TitlebarOptions { + title: Some("T3 Code".into()), + ..Default::default() + }), + ..Default::default() + }, + |_, cx| { + cx.new(|cx| { + cx.spawn(async move |this, cx| { + while let Ok(status) = status_rx.recv().await { + let updated = this.update(cx, |view: &mut StatusView, cx| { + view.status = status.to_string().into(); + cx.notify(); + }); + if updated.is_err() { + break; + } + } + }) + .detach(); + StatusView { + status: initial.to_string().into(), + } + }) + }, + ) + .expect("failed to open the main window"); + cx.activate(true); + }); + drop(runtime); +} diff --git a/apps/desktop-gpui/src/server.rs b/apps/desktop-gpui/src/server.rs new file mode 100644 index 000000000000..3d4873b2c3ba --- /dev/null +++ b/apps/desktop-gpui/src/server.rs @@ -0,0 +1,404 @@ +//! Spawns and supervises the T3 server the way apps/desktop does +//! (DesktopBackendConfiguration.ts and DesktopBackendManager.ts): a JSON +//! bootstrap line on fd 3, readiness on /.well-known/t3/environment, SIGTERM +//! then SIGKILL on stop. + +use crate::auth; +use crate::config::{Config, ServerCommand}; +use crate::connection::{self, Backoff}; +use crate::status::{Status, StatusSender, publish}; +use serde::{Deserialize, Serialize}; +use std::io; +use std::process::Stdio; +use std::sync::Mutex; +use std::time::Duration; +use tokio::io::{AsyncBufReadExt, AsyncRead, BufReader}; +use tokio::process::{Child, Command}; +use tokio::sync::watch; +use tokio::time::{Instant, sleep, timeout}; + +const READINESS_PATH: &str = "/.well-known/t3/environment"; +// Large state databases can take over a minute to become ready. +const READINESS_TIMEOUT: Duration = Duration::from_secs(3 * 60); +const READINESS_INTERVAL: Duration = Duration::from_millis(100); +const READINESS_REQUEST_TIMEOUT: Duration = Duration::from_secs(1); +// Matches the service launcher's grace so the server can stop provider sessions. +const TERMINATE_GRACE: Duration = Duration::from_secs(5); +// A run this long counts as healthy, so the next failure restarts quickly again. +const HEALTHY_UPTIME: Duration = Duration::from_secs(30); +const BOOTSTRAP_FD: i32 = 3; +// packages/contracts/src/desktopBootstrap.ts: refusals a restart cannot fix. +const EXIT_MANAGED_POLICY: i32 = 77; +const EXIT_STATE_DIR_OWNED: i32 = 78; + +// The server takes these from the bootstrap payload, never from the launcher's shell. +const ENV_REMOVED_FROM_SERVER: &[&str] = &[ + "T3CODE_PORT", + "T3CODE_MODE", + "T3CODE_NO_BROWSER", + "T3CODE_HOST", + "T3CODE_DESKTOP_WS_URL", + "T3CODE_DESKTOP_LAN_HOST", + "T3CODE_DESKTOP_HTTPS_ENDPOINTS", + "T3CODE_TAILSCALE_SERVE", + "T3CODE_TAILSCALE_SERVE_PORT", +]; + +/// Shape of packages/contracts/src/desktopBootstrap.ts. Optional telemetry and +/// browser descriptors are omitted: this client provides neither. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Bootstrap<'a> { + mode: &'static str, + no_browser: bool, + port: u16, + t3_home: &'a str, + host: &'static str, + desktop_bootstrap_token: String, + desktop_bootstrap_secret: &'a str, + // The schema requires both even with Tailscale Serve off. + tailscale_serve_enabled: bool, + tailscale_serve_port: u16, +} + +fn bootstrap_line(config: &Config, secret: &str) -> String { + let bootstrap = Bootstrap { + mode: "desktop", + no_browser: true, + port: config.port, + t3_home: &config.t3_home, + host: "127.0.0.1", + desktop_bootstrap_token: auth::bootstrap_token(secret, auth::now_ms()), + desktop_bootstrap_secret: secret, + tailscale_serve_enabled: false, + tailscale_serve_port: 443, + }; + let mut line = serde_json::to_string(&bootstrap).expect("bootstrap serializes"); + line.push('\n'); + line +} + +/// Pipe pre-filled with `payload`, write end already closed so the server sees EOF. +#[cfg(unix)] +fn bootstrap_pipe(payload: &[u8]) -> io::Result { + use std::io::Write; + use std::os::fd::{FromRawFd, OwnedFd}; + + let mut fds = [0i32; 2]; + // SAFETY: `fds` holds two ints, as pipe(2) requires. + if unsafe { libc::pipe(fds.as_mut_ptr()) } != 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: both descriptors were just created and are owned by nobody else. + let (read, write) = unsafe { (OwnedFd::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) }; + for fd in [&read, &write] { + use std::os::fd::AsRawFd; + // Only the descriptor dup'd onto fd 3 may survive exec. + // SAFETY: valid, owned descriptor. + if unsafe { libc::fcntl(fd.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC) } != 0 { + return Err(io::Error::last_os_error()); + } + } + // The payload is far below the pipe capacity, so this never blocks. + std::fs::File::from(write).write_all(payload)?; + Ok(read) +} + +#[cfg(unix)] +fn spawn_server(config: &Config, secret: &str) -> io::Result { + use std::os::fd::AsRawFd; + + let ServerCommand { program, args } = &config.command; + let pipe = bootstrap_pipe(bootstrap_line(config, secret).as_bytes())?; + let source = pipe.as_raw_fd(); + let mut command = Command::new(program); + command + .args(args) + .args(["--bootstrap-fd", &BOOTSTRAP_FD.to_string()]) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + // Last resort if this task is dropped; the explicit stop path is `terminate`. + .kill_on_drop(true); + for name in ENV_REMOVED_FROM_SERVER { + command.env_remove(name); + } + // SAFETY: the closure only calls async-signal-safe fcntl/dup2. + unsafe { + command.pre_exec(move || { + if source == BOOTSTRAP_FD { + let flags = libc::fcntl(source, libc::F_GETFD); + if flags < 0 || libc::fcntl(source, libc::F_SETFD, flags & !libc::FD_CLOEXEC) < 0 { + return Err(io::Error::last_os_error()); + } + } else if libc::dup2(source, BOOTSTRAP_FD) < 0 { + return Err(io::Error::last_os_error()); + } + Ok(()) + }); + } + let child = command.spawn(); + drop(pipe); + child +} + +#[cfg(not(unix))] +fn spawn_server(_config: &Config, _secret: &str) -> io::Result { + Err(io::Error::new( + io::ErrorKind::Unsupported, + "starting the server needs a unix fd 3 pipe", + )) +} + +fn forward_output(stream: impl AsyncRead + Unpin + Send + 'static) { + tokio::spawn(async move { + let mut lines = BufReader::new(stream).lines(); + while let Ok(Some(line)) = lines.next_line().await { + eprintln!("[server] {line}"); + } + }); +} + +#[derive(Deserialize)] +struct Descriptor { + label: String, +} + +/// Polls until the descriptor answers, returning the environment label. +async fn wait_until_ready(client: &reqwest::Client, http_base: &str) -> Result { + let url = format!("{http_base}{READINESS_PATH}"); + let deadline = Instant::now() + READINESS_TIMEOUT; + loop { + let attempt = client + .get(&url) + .timeout(READINESS_REQUEST_TIMEOUT) + .send() + .await; + if let Ok(response) = attempt + && response.status().is_success() + && let Ok(descriptor) = response.json::().await + { + return Ok(descriptor.label); + } + if Instant::now() >= deadline { + return Err(format!( + "Server did not become ready within {}s", + READINESS_TIMEOUT.as_secs() + )); + } + sleep(READINESS_INTERVAL).await; + } +} + +/// SIGTERM the child this process spawned, then SIGKILL after the grace period. +async fn terminate(child: &mut Child) { + #[cfg(unix)] + if let Some(pid) = child.id() { + // SAFETY: `pid` is the id of the child we spawned and have not yet reaped. + unsafe { libc::kill(pid as i32, libc::SIGTERM) }; + } + if timeout(TERMINATE_GRACE, child.wait()).await.is_err() { + let _ = child.kill().await; + } +} + +async fn shutdown_requested(shutdown: &mut watch::Receiver) { + let _ = shutdown.wait_for(|requested| *requested).await; +} + +/// Waits out `delay`; true when shutdown arrived first. +async fn sleep_or_shutdown(delay: Duration, shutdown: &mut watch::Receiver) -> bool { + tokio::select! { + _ = sleep(delay) => false, + _ = shutdown_requested(shutdown) => true, + } +} + +async fn session(config: &Config, secret: &str, status: &StatusSender) -> String { + let client = auth::http_client(); + match wait_until_ready(&client, &config.http_base()).await { + Ok(label) => { + connection::run(config, secret, &label, status).await; + unreachable_session() + } + Err(message) => message, + } +} + +// `connection::run` only ends by cancellation. +fn unreachable_session() -> String { + "connection ended".to_owned() +} + +/// Runs the server and its connection until shutdown, restarting after crashes. +pub async fn supervise(config: Config, status: StatusSender, mut shutdown: watch::Receiver) { + let mut backoff = Backoff::new(); + loop { + publish(&status, Status::Starting); + let secret = match auth::generate_secret() { + Ok(secret) => secret, + Err(message) => return publish(&status, Status::Failed(message)), + }; + let started = Instant::now(); + let mut child = match spawn_server(&config, &secret) { + Ok(child) => child, + Err(error) => { + publish( + &status, + Status::Failed(format!("Could not start the server: {error}")), + ); + if sleep_or_shutdown(backoff.next_delay(), &mut shutdown).await { + return; + } + continue; + } + }; + if let Some(stdout) = child.stdout.take() { + forward_output(stdout); + } + if let Some(stderr) = child.stderr.take() { + forward_output(stderr); + } + + let failure = tokio::select! { + _ = shutdown_requested(&mut shutdown) => { + terminate(&mut child).await; + return; + } + exited = child.wait() => { + let code = exited.ok().and_then(|exit| exit.code()); + match code { + Some(EXIT_STATE_DIR_OWNED) => { + publish(&status, Status::Failed( + "Another T3 Code server already owns this T3 home. Stop it or use a different home.".to_owned(), + )); + shutdown_requested(&mut shutdown).await; + return; + } + Some(EXIT_MANAGED_POLICY) => { + publish(&status, Status::Failed("The server was refused by managed policy.".to_owned())); + shutdown_requested(&mut shutdown).await; + return; + } + Some(code) => format!("Server exited with code {code}"), + None => "Server exited unexpectedly".to_owned(), + } + } + message = session(&config, &secret, &status) => { + terminate(&mut child).await; + message + } + }; + if started.elapsed() >= HEALTHY_UPTIME { + backoff.reset(); + } + publish(&status, Status::Failed(format!("{failure}. Restarting..."))); + if sleep_or_shutdown(backoff.next_delay(), &mut shutdown).await { + return; + } + } +} + +/// Handle for stopping the supervisor from the UI thread at quit. +pub struct Supervisor { + shutdown: watch::Sender, + done: Mutex>>, +} + +impl Supervisor { + pub fn start(runtime: &tokio::runtime::Handle, config: Config, status: StatusSender) -> Self { + let (shutdown, shutdown_rx) = watch::channel(false); + let (done_tx, done_rx) = std::sync::mpsc::channel(); + runtime.spawn(async move { + supervise(config, status, shutdown_rx).await; + let _ = done_tx.send(()); + }); + Self { + shutdown, + done: Mutex::new(Some(done_rx)), + } + } + + /// Blocks until the server we spawned is gone (bounded by the grace period). + pub fn stop(&self) { + let _ = self.shutdown.send(true); + let done = self.done.lock().ok().and_then(|mut done| done.take()); + if let Some(done) = done { + let _ = done.recv_timeout(TERMINATE_GRACE + Duration::from_secs(2)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config() -> Config { + Config { + port: 13773, + t3_home: "/work/.t3".to_owned(), + command: ServerCommand { + program: "node".to_owned(), + args: vec!["/work/apps/server/src/bin.ts".to_owned()], + }, + } + } + + #[test] + fn bootstrap_line_matches_the_contract() { + let line = bootstrap_line(&config(), "secret"); + assert!(line.ends_with('\n')); + let value: serde_json::Value = serde_json::from_str(line.trim_end()).expect("json"); + assert_eq!(value["mode"], "desktop"); + assert_eq!(value["noBrowser"], true); + assert_eq!(value["port"], 13773); + assert_eq!(value["host"], "127.0.0.1"); + assert_eq!(value["t3Home"], "/work/.t3"); + assert_eq!(value["desktopBootstrapSecret"], "secret"); + assert_eq!(value["tailscaleServeEnabled"], false); + assert_eq!(value["tailscaleServePort"], 443); + assert_eq!( + value["desktopBootstrapToken"].as_str().map(str::len), + Some(64) + ); + for omitted in [ + "desktopTelemetryFd", + "desktopBrowserFd", + "shellEnvironmentPrepared", + ] { + assert!(value.get(omitted).is_none(), "{omitted} must be omitted"); + } + } + + #[cfg(unix)] + #[tokio::test] + async fn child_reads_the_bootstrap_line_from_fd_3() { + let mut config = config(); + config.command = ServerCommand { + program: "/bin/sh".to_owned(), + // `--bootstrap-fd 3` becomes $1 and $2 of the script. + args: vec![ + "-c".to_owned(), + "head -n 1 <&3; exit 7".to_owned(), + "sh".to_owned(), + ], + }; + let mut child = spawn_server(&config, "secret").expect("spawn"); + let output = { + use tokio::io::AsyncReadExt; + let mut text = String::new(); + child + .stdout + .take() + .expect("stdout") + .read_to_string(&mut text) + .await + .expect("read"); + text + }; + let exit = child.wait().await.expect("wait"); + assert_eq!(exit.code(), Some(7)); + let value: serde_json::Value = serde_json::from_str(output.trim()).expect("json line"); + assert_eq!(value["desktopBootstrapSecret"], "secret"); + } +} diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs new file mode 100644 index 000000000000..9e7cbd99470f --- /dev/null +++ b/apps/desktop-gpui/src/status.rs @@ -0,0 +1,30 @@ +use std::fmt; + +/// What the window shows about the managed server and its socket. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Status { + NotConnected, + Starting, + Connecting, + Connected(String), + Failed(String), +} + +impl fmt::Display for Status { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NotConnected => f.write_str("Not connected"), + Self::Starting => f.write_str("Starting server..."), + Self::Connecting => f.write_str("Connecting..."), + Self::Connected(label) => write!(f, "Connected to {label}"), + Self::Failed(message) => f.write_str(message), + } + } +} + +pub type StatusSender = async_channel::Sender; + +pub fn publish(sender: &StatusSender, status: Status) { + // Unbounded, so this only fails once the window is gone. + let _ = sender.try_send(status); +} diff --git a/package.json b/package.json index 2824692d78f7..8a6519a35940 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "dev:web": "node scripts/dev-runner.ts dev:web", "dev:marketing": "vp run --filter @t3tools/marketing dev", "dev:desktop": "node scripts/dev-runner.ts dev:desktop", + "dev:desktop-gpui": "node scripts/dev-runner.ts dev:desktop-gpui", "migrate-dev-db": "node apps/server/scripts/migrate-dev-db.ts", "start": "vp run --filter t3 start", "start:desktop": "vp run --filter @t3tools/desktop start", diff --git a/scripts/dev-runner.test.ts b/scripts/dev-runner.test.ts index 34b768319c2f..f01616da5b0c 100644 --- a/scripts/dev-runner.test.ts +++ b/scripts/dev-runner.test.ts @@ -93,6 +93,41 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { assert.equal(desktop.T3CODE_DEV_AUTH_TOKEN, undefined); }), ); + it.effect("hands the gpui app its server port, home and entry without web URLs", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const input = { + mode: "dev:desktop-gpui", + baseEnv: { + VITE_HTTP_URL: "http://localhost:1", + VITE_WS_URL: "ws://localhost:1", + T3CODE_PORT: "1", + T3CODE_HOME: "/ambient/home", + }, + serverOffset: 3, + webOffset: 3, + t3Home: "/tmp/worktree/.t3", + browser: undefined, + autoBootstrapProjectFromCwd: undefined, + logWebSocketEvents: undefined, + host: undefined, + port: undefined, + devUrl: undefined, + } as const; + const env = yield* createDevRunnerEnv(input); + const fallback = yield* createDevRunnerEnv({ ...input, t3Home: undefined }); + + assert.equal(env.T3CODE_GPUI_SERVER_PORT, String(13_773 + 3)); + assert.equal(env.T3CODE_GPUI_HOME, path.resolve("/tmp/worktree/.t3")); + assert.isTrue(path.isAbsolute(env.T3CODE_GPUI_SERVER_ENTRY ?? "")); + assert.isTrue(env.T3CODE_GPUI_SERVER_ENTRY?.endsWith(path.join("apps/server/src/bin.ts"))); + assert.equal(env.VITE_HTTP_URL, undefined); + assert.equal(env.VITE_WS_URL, undefined); + assert.equal(env.T3CODE_PORT, undefined); + assert.notEqual(fallback.T3CODE_GPUI_HOME, path.join(NodeOS.homedir(), ".t3")); + }), + ); + it.effect("keeps the desktop profile inside an isolated home only", () => Effect.gen(function* () { const path = yield* Path.Path; @@ -371,6 +406,21 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { }), ); + it.effect("shifts only server offset for dev:desktop-gpui", () => + Effect.gen(function* () { + const taken = new Set([13773]); + const offsets = yield* resolveModePortOffsets({ + mode: "dev:desktop-gpui", + startOffset: 0, + hasExplicitServerPort: false, + hasExplicitDevUrl: false, + checkPortAvailability: (port) => Effect.succeed(!taken.has(port)), + }); + + assert.deepStrictEqual(offsets, { serverOffset: 1, webOffset: 1 }); + }), + ); + it.effect("respects explicit dev-url override for dev:web", () => Effect.gen(function* () { const offsets = yield* resolveModePortOffsets({ @@ -658,6 +708,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { { mode: "dev", share: false }, { mode: "dev:web", share: true }, { mode: "dev:desktop", share: true }, + { mode: "dev:desktop-gpui", share: true }, ] as const)("rejects --built for $mode with share=$share", ({ mode, share }) => { const spawnerLayer = Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, diff --git a/scripts/dev-runner.ts b/scripts/dev-runner.ts index 55ff6ac0513d..a449d73ea7a3 100644 --- a/scripts/dev-runner.ts +++ b/scripts/dev-runner.ts @@ -76,6 +76,8 @@ const MODE_ARGS = { "dev:server": ["run", "--filter=t3", "dev"], "dev:web": ["run", "--filter=@t3tools/web", "dev"], "dev:desktop": ["run", "--filter=@t3tools/desktop", "--filter=@t3tools/web", "dev"], + // The native client spawns its own server; no Vite or web server runs. + "dev:desktop-gpui": ["run", "--filter=@t3tools/desktop-gpui", "dev"], } as const satisfies Record>; // `--built`: the web package rebuilds into apps/web/dist and the backend serves @@ -146,7 +148,7 @@ export class DevRunnerProcessError extends Schema.TaggedError()( "DevRunnerProcessExitError", { - mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]), + mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop", "dev:desktop-gpui"]), executable: Schema.Literal("vp"), argumentCount: Schema.Number, shell: Schema.Boolean, @@ -197,7 +199,7 @@ export class DevRunnerHostNotProxiableError extends Schema.TaggedError()( "DevRunnerBuiltNeedsShareError", { - mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop"]), + mode: Schema.Literals(["dev", "dev:server", "dev:web", "dev:desktop", "dev:desktop-gpui"]), }, ) { override get message(): string { @@ -331,6 +333,7 @@ export const createDevRunnerEnv = Effect.fn("dev-runner.createDevRunnerEnv")(fun const configuredBaseDir = t3Home?.trim() || undefined; const resolvedBaseDir = yield* resolveBaseDir(configuredBaseDir); const isDesktopMode = mode === "dev:desktop"; + const isGpuiMode = mode === "dev:desktop-gpui"; const output: NodeJS.ProcessEnv = { ...baseEnv, @@ -340,7 +343,17 @@ export const createDevRunnerEnv = Effect.fn("dev-runner.createDevRunnerEnv")(fun `http://${isDesktopMode ? DESKTOP_DEV_LOOPBACK_HOST : "localhost"}:${webPort}`, }; - if (configuredBaseDir !== undefined) { + if (isGpuiMode) { + // The app hands this home to its server through the bootstrap payload, so it + // must never fall back to the live ~/.t3. + const path = yield* Path.Path; + const gpuiHome = + configuredBaseDir === undefined + ? path.join(path.dirname(import.meta.dirname), ".t3") + : resolvedBaseDir; + output.T3CODE_HOME = gpuiHome; + output.T3CODE_GPUI_HOME = gpuiHome; + } else if (configuredBaseDir !== undefined) { output.T3CODE_HOME = resolvedBaseDir; } else { delete output.T3CODE_HOME; @@ -355,7 +368,33 @@ export const createDevRunnerEnv = Effect.fn("dev-runner.createDevRunnerEnv")(fun // install of the developer's real accounts. Set it explicitly to opt in. output.T3CODE_TELEMETRY_ENABLED = baseEnv.T3CODE_TELEMETRY_ENABLED ?? "false"; - if (!isDesktopMode) { + if (isGpuiMode) { + const path = yield* Path.Path; + output.T3CODE_GPUI_SERVER_PORT = String(serverPort); + output.T3CODE_GPUI_SERVER_COMMAND = yield* HostProcess.ExecutablePath; + output.T3CODE_GPUI_SERVER_ENTRY = path.join( + path.dirname(import.meta.dirname), + "apps/server/src/bin.ts", + ); + // No web client runs, so nothing may point one at a backend, and the app's + // server takes its settings from the bootstrap payload alone. + for (const name of [ + "PORT", + "HOST", + "VITE_DEV_SERVER_URL", + "VITE_HTTP_URL", + "VITE_WS_URL", + "T3CODE_PORT", + "T3CODE_MODE", + "T3CODE_NO_BROWSER", + "T3CODE_HOST", + "T3CODE_DESKTOP_WS_URL", + "T3CODE_SINGLE_ORIGIN_DEV", + "T3CODE_DEV_AUTH_TOKEN", + ]) { + delete output[name]; + } + } else if (!isDesktopMode) { output.T3CODE_PORT = String(serverPort); // An inherited HOST pins Vite's HMR socket via explicitHost in apps/web/vite.config.ts. // Only desktop mode should set it. @@ -389,11 +428,11 @@ export const createDevRunnerEnv = Effect.fn("dev-runner.createDevRunnerEnv")(fun delete output.T3CODE_DEV_AUTH_TOKEN; } - if (!isDesktopMode && host !== undefined) { + if (!isDesktopMode && !isGpuiMode && host !== undefined) { output.T3CODE_HOST = host; } - if (!isDesktopMode) { + if (!isDesktopMode && !isGpuiMode) { output.T3CODE_NO_BROWSER = browser === true ? "0" : "1"; } @@ -583,7 +622,7 @@ export const resolveModePortOffsets = Effect.fn("dev-runner.resolveModePortOffse return { serverOffset: startOffset, webOffset }; } - if (mode === "dev:server") { + if (mode === "dev:server" || mode === "dev:desktop-gpui") { if (hasExplicitServerPort) { return { serverOffset: startOffset, webOffset: startOffset }; } @@ -686,6 +725,7 @@ const prepareFirstStart = Effect.fn("dev-runner.prepareFirstStart")(function* (i const repoRoot = path.dirname(import.meta.dirname); if ( input.mode !== "dev:server" && + input.mode !== "dev:desktop-gpui" && !(yield* exists(path.join(repoRoot, "apps/web/node_modules/.vite/deps/_metadata.json"))) ) { yield* runRepoScript("apps/web/scripts/warm-dep-cache.ts", [], input.env); @@ -806,7 +846,7 @@ export const runDevRunnerWithInput = Effect.fn("dev-runner.runDevRunnerWithInput const baseDir = env.T3CODE_HOME ?? (yield* DEFAULT_T3_HOME); yield* Effect.logInfo( - `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`, + `[dev-runner] mode=${input.mode} source=${source}${selectionSuffix} serverPort=${String(env.T3CODE_PORT ?? env.T3CODE_GPUI_SERVER_PORT)} webPort=${String(env.PORT)} baseDir=${baseDir}`, ); // Mirrors the server's deriveServerPaths: an explicit home uses userdata. @@ -816,7 +856,7 @@ export const runDevRunnerWithInput = Effect.fn("dev-runner.runDevRunnerWithInput ); if ( !input.dryRun && - (input.mode === "dev" || input.mode === "dev:server") && + (input.mode === "dev" || input.mode === "dev:server" || input.mode === "dev:desktop-gpui") && (yield* isStateDirOwned(stateDir)) ) { return yield* new DevRunnerStateDirOwnedError({ stateDir }); @@ -838,6 +878,10 @@ export const runDevRunnerWithInput = Effect.fn("dev-runner.runDevRunnerWithInput yield* Effect.logWarning( "[dev-runner] --share is not supported for dev:desktop (the renderer is pinned to loopback). Use `dev`, which runs the whole browser stack.", ); + } else if (input.mode === "dev:desktop-gpui") { + yield* Effect.logWarning( + "[dev-runner] --share is not supported for dev:desktop-gpui (the app talks to its own loopback server). Use `dev`, which runs the whole browser stack.", + ); } else { // acquireRelease, not share-then-addFinalizer: the mapping outlives this // process (and reboots), so the cleanup has to be registered atomically From 4199241d43f4ab6abca638e8119c3e944abdf261 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 4 Nov 2026 23:35:06 -0500 Subject: [PATCH 02/82] feat(desktop-gpui): typed RPC wire types generated from contracts Hand-porting 212 RPC methods to Rust would drift from the TypeScript contracts. Export a JSON Schema from WsRpcGroup, check it for drift in CI, and generate the Rust types from it so the native client decodes the shell subscription with the same shapes the server sends. Signed-off-by: Yordis Prieto --- .github/workflows/ci.yml | 3 + apps/desktop-gpui/Cargo.lock | 168 +- apps/desktop-gpui/Cargo.toml | 6 + apps/desktop-gpui/README.md | 19 +- apps/desktop-gpui/src/connection.rs | 79 +- apps/desktop-gpui/src/main.rs | 3 + apps/desktop-gpui/src/rpc.rs | 285 + apps/desktop-gpui/src/shell.rs | 221 + apps/desktop-gpui/src/status.rs | 71 +- apps/desktop-gpui/src/wire/generated.rs | 4058 ++ apps/desktop-gpui/src/wire/mod.rs | 11 + .../tests/fixtures/shell-project-updated.json | 14 + .../tests/fixtures/shell-snapshot.json | 61 + .../tests/fixtures/shell-thread-updated.json | 44 + apps/desktop-gpui/tests/wire_types.rs | 133 + packages/contracts/package.json | 1 + packages/contracts/schema/rpc.schema.json | 52466 ++++++++++++++++ .../scripts/export-rpc-json-schema.ts | 316 + scripts/check-changed.ts | 5 + vite.config.ts | 2 + 20 files changed, 57950 insertions(+), 16 deletions(-) create mode 100644 apps/desktop-gpui/src/rpc.rs create mode 100644 apps/desktop-gpui/src/shell.rs create mode 100644 apps/desktop-gpui/src/wire/generated.rs create mode 100644 apps/desktop-gpui/src/wire/mod.rs create mode 100644 apps/desktop-gpui/tests/fixtures/shell-project-updated.json create mode 100644 apps/desktop-gpui/tests/fixtures/shell-snapshot.json create mode 100644 apps/desktop-gpui/tests/fixtures/shell-thread-updated.json create mode 100644 apps/desktop-gpui/tests/wire_types.rs create mode 100644 packages/contracts/schema/rpc.schema.json create mode 100644 packages/contracts/scripts/export-rpc-json-schema.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9253bd23eb8..dc54cf1c588d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -109,6 +109,9 @@ jobs: - name: Check generated Expo GraphQL operations run: vp run --filter t3 generate --check + - name: Check generated RPC JSON Schema + run: vp run --filter @t3tools/contracts generate --check + - name: Check run: vp check diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index 7691fef2bbe9..d2c4c67b10c2 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -707,6 +707,19 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "codespace" +version = "0.0.1-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9b10f68d82f8de01589cd0ab1a21cddaf271b2a936d0bb2518c56a3498efe2f" +dependencies = [ + "proc-macro2", + "quote", + "semver", + "syn 3.0.9", + "version-ranges", +] + [[package]] name = "codespan-reporting" version = "0.13.1" @@ -1692,7 +1705,7 @@ dependencies = [ "regex", "resvg", "scheduler", - "schemars", + "schemars 1.2.3", "seahash", "serde", "serde_json", @@ -1871,7 +1884,7 @@ name = "gpui_shared_string" version = "0.1.0" source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" dependencies = [ - "schemars", + "schemars 1.2.3", "serde", "smol_str", ] @@ -3689,6 +3702,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.9", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -4155,6 +4178,15 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "regress" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32eef8b209c3c1c15dbad02c1f30f9539f00dc7253e0cbcdaae442a50a09d7c1" +dependencies = [ + "memchr", +] + [[package]] name = "renderdoc-sys" version = "1.1.0" @@ -4433,6 +4465,18 @@ dependencies = [ "web-time", ] +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "schemars_derive 0.8.22", + "serde", + "serde_json", +] + [[package]] name = "schemars" version = "1.2.3" @@ -4442,11 +4486,23 @@ dependencies = [ "dyn-clone", "indexmap", "ref-cast", - "schemars_derive", + "schemars_derive 1.2.3", "serde", "serde_json", ] +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals 0.29.1", + "syn 2.0.119", +] + [[package]] name = "schemars_derive" version = "1.2.3" @@ -4455,7 +4511,7 @@ checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" dependencies = [ "proc-macro2", "quote", - "serde_derive_internals", + "serde_derive_internals 0.30.0", "syn 3.0.9", ] @@ -4550,6 +4606,17 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "serde_derive_internals" version = "0.30.0" @@ -4605,6 +4672,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_tokenstream" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db95b8acf6eca5bc1bc771a7d7fb1a0d1485bdef0c46eec2f573bbaf4b4118f8" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4985,12 +5064,16 @@ dependencies = [ "hex", "hmac 0.13.0", "libc", + "prettyplease", "reqwest", + "schemars 0.8.22", "serde", "serde_json", "sha2 0.11.1", + "syn 3.0.9", "tokio", "tokio-tungstenite", + "typify", ] [[package]] @@ -5388,6 +5471,73 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "typespace" +version = "0.0.1-alpha.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a6d2e65af738a56413ffeec4a912246a37cbc3e95e7bf5241d971bdce10ae47" +dependencies = [ + "codespace", + "heck 0.5.0", + "log", + "proc-macro2", + "quote", + "schemars 0.8.22", + "serde", + "serde_json", + "strum", + "syn 3.0.9", + "thiserror", +] + +[[package]] +name = "typify" +version = "0.10.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96a0d41e55c21fa2d06a672c7306dac957197c16ab527d0cdf6c33b910126fc" +dependencies = [ + "typify-impl", + "typify-macro", +] + +[[package]] +name = "typify-impl" +version = "0.10.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b487fba77e13eee8caea824dc8c8c94457cb08040ea60ae3e26a649e504a6281" +dependencies = [ + "heck 0.5.0", + "log", + "proc-macro2", + "quote", + "regress", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "syn 3.0.9", + "thiserror", + "typespace", + "unicode-ident", +] + +[[package]] +name = "typify-macro" +version = "0.10.0-alpha.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c697dad0e4ddb1b73a0bf780cd44c6487a986afc8b0826fe2e11a067c81b528" +dependencies = [ + "proc-macro2", + "quote", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "serde_tokenstream", + "syn 3.0.9", + "typify-impl", +] + [[package]] name = "uds_windows" version = "1.2.1" @@ -5543,6 +5693,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "version-ranges" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e9bd4e9c9ff6a2a9b5969462ba26216af3e010df0377dad8320ab515262ef8" +dependencies = [ + "semver", + "smallvec", +] + [[package]] name = "version_check" version = "0.9.5" diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index cb23e47de817..a3d580d795e1 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -34,3 +34,9 @@ tokio-tungstenite = "0.30.0" codegen-units = 1 lto = "thin" strip = true + +[dev-dependencies] +prettyplease = "0.3.0" +schemars = "0.8" +syn = "3.0.9" +typify = "0.10.0-alpha.3" diff --git a/apps/desktop-gpui/README.md b/apps/desktop-gpui/README.md index f8d890596ecd..4262d1e05ad1 100644 --- a/apps/desktop-gpui/README.md +++ b/apps/desktop-gpui/README.md @@ -2,7 +2,7 @@ Native desktop client written in Rust with [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui). It is meant to replace `apps/desktop` (Electron) over time and never embeds Node or Chromium. It starts the T3 server as a child process, authenticates without pairing, and opens the RPC WebSocket. -Status: connection shell. The window shows the connection state ("Starting server...", "Connecting...", "Connected to ", or the error). There are no RPC methods or screens yet. +Status: connection shell. The window shows the connection state ("Starting server...", "Connecting...", "Connected to · N projects · M threads", or the error). The counts come from a live `orchestration.subscribeShell` subscription. There are no screens yet. ## Run @@ -35,10 +35,25 @@ vp run --filter @t3tools/desktop-gpui test # cargo test 1. Spawns ` [entry] --bootstrap-fd 3` and writes one JSON bootstrap line (`packages/contracts/src/desktopBootstrap.ts`) to a pipe dup'd onto fd 3, as Electron does. The secret is random per launch. 2. Polls `GET /.well-known/t3/environment` until the server is ready. 3. Exchanges the HMAC bootstrap token for a bearer session, mints a WebSocket ticket, and opens `/ws`. -4. Sends an RPC `Ping` every 5 seconds and reconnects with backoff after 15 seconds of silence. +4. Subscribes to `orchestration.subscribeShell`, acks each stream chunk, and reduces snapshot and delta items into project and thread counts (`src/rpc.rs`, `src/shell.rs`). +5. Sends an RPC `Ping` every 5 seconds and reconnects with backoff after 15 seconds of silence or when the stream ends. On quit the app sends SIGTERM to the server it spawned and SIGKILL after 5 seconds. If the app is killed, the server notices its parent changed within about 2 seconds and shuts itself down. +## Wire types + +`packages/contracts/schema/rpc.schema.json` is generated from the Effect schemas (`vp run --filter @t3tools/contracts generate`, or `generate --check` to verify). It holds the encoded side of every `WsRpcGroup` method as shared `$defs` plus a `methods` index (payload, success, error refs, stream flag). + +`src/wire/generated.rs` is generated from the methods this client calls (`METHODS` in `tests/wire_types.rs`) with [typify](https://github.com/oxidecomputer/typify) and committed, so normal builds need no codegen and diffs stay reviewable. To add a method, add its tag to `METHODS`, then regenerate: + +```sh +T3_UPDATE_GENERATED=1 cargo test --test wire_types +``` + +Without the variable the same test fails when the committed file is stale. Drift in the schema itself is caught by `generate --check` in CI and `vp run check:changed`. + +Known gaps: typify cannot flatten the `projectIcon` union (an `anyOf` with a `{kind: string}` catch-all), so `ProjectsEntry.projectIcon` is kept opaque (`serde_json::Value`) through the `OPAQUE` list. Unions without a discriminator generate as `#[serde(untagged)]` enums. + ## Notes - `gpui` and `gpui_platform` are pinned to one `zed-industries/zed` commit in `Cargo.toml`. Bump both together. diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 3751bb282cb1..60f2dbd9eb47 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -2,14 +2,15 @@ use crate::auth::{self, AuthError}; use crate::config::Config; -use crate::status::{Status, StatusSender, publish}; +use crate::rpc::{Event, PING_FRAME, Session, SubscribeShell}; +use crate::shell::ShellState; +use crate::status::{Counts, Status, StatusSender, publish}; +use crate::wire::{ShellItem, SubscribeShellPayload}; use futures_util::{SinkExt, StreamExt}; use std::time::Duration; use tokio::time::{Instant, MissedTickBehavior, interval, sleep, sleep_until}; use tokio_tungstenite::tungstenite::Message; -/// Effect RPC ping envelope (RpcMessage.constPing) in the JSON serialization. -const PING_FRAME: &str = r#"{"_tag":"Ping"}"#; const PING_INTERVAL: Duration = Duration::from_secs(5); // Pings go out every 5 s and a busy server stops answering them, so allow 15 s // without any frame (packages/client-runtime/src/rpc/protocol.ts PING_TIMEOUT). @@ -67,9 +68,15 @@ pub async fn run(config: &Config, secret: &str, label: &str, status: &StatusSend publish(status, Status::Connecting); let failure = match connect(&client, config, &http_base, secret, &mut bearer).await { Ok(socket) => { - publish(status, Status::Connected(label.to_owned())); + publish( + status, + Status::Connected { + label: label.to_owned(), + counts: None, + }, + ); backoff.reset(); - serve(socket).await + serve(socket, label, status).await } Err(message) => message, }; @@ -114,9 +121,18 @@ async fn connect( Ok(socket) } -/// Pings until the socket closes or goes quiet; returns why it ended. -async fn serve(socket: Socket) -> String { +/// Subscribes to the shell stream and keeps the socket alive; returns why it ended. +async fn serve(socket: Socket, label: &str, status: &StatusSender) -> String { let (mut sink, mut stream) = socket.split(); + let mut session = Session::new(); + let mut shell = ShellState::default(); + let request = match session.request::(&SubscribeShellPayload::default()) { + Ok((_, frame)) => frame, + Err(error) => return error, + }; + if let Err(error) = sink.send(Message::text(request)).await { + return error.to_string(); + } let mut ping = interval(PING_INTERVAL); ping.set_missed_tick_behavior(MissedTickBehavior::Delay); let mut deadline = Instant::now() + PING_TIMEOUT; @@ -138,13 +154,60 @@ async fn serve(socket: Socket) -> String { .map(|frame| format!("connection closed: {}", frame.reason)) .unwrap_or_else(|| "connection closed".to_owned()); } - // No RPC methods yet: any frame, Pong included, proves the server is alive. + Some(Ok(Message::Text(text))) => { + // Any frame, Pong included, proves the server is alive. + deadline = Instant::now() + PING_TIMEOUT; + let reaction = session.react(text.as_str()); + for reply in reaction.send { + if let Err(error) = sink.send(Message::text(reply)).await { + return error.to_string(); + } + } + for event in reaction.events { + if let Some(reason) = handle(event, &mut shell, label, status) { + return reason; + } + } + } Some(Ok(_)) => deadline = Instant::now() + PING_TIMEOUT, } } } } +/// Applies one event; `Some(reason)` means the subscription is over and the socket should reopen. +fn handle( + event: Event, + shell: &mut ShellState, + label: &str, + status: &StatusSender, +) -> Option { + match event { + Event::Item { value, .. } => match serde_json::from_value::(value) { + Ok(item) => { + if shell.apply(item) && shell.is_seeded() { + publish( + status, + Status::Connected { + label: label.to_owned(), + counts: Some(Counts { + projects: shell.project_count(), + threads: shell.thread_count(), + }), + }, + ); + } + None + } + Err(error) => Some(format!("unreadable shell item: {error}")), + }, + Event::Done { .. } => Some("shell stream ended".to_owned()), + Event::Failed { message, .. } => Some(format!("shell stream failed: {message}")), + Event::Defect(message) => Some(format!("server defect: {message}")), + Event::Pong | Event::Ignored => None, + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index cce74b27c978..c3cbc50c93bb 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -1,8 +1,11 @@ mod auth; mod config; mod connection; +mod rpc; mod server; +mod shell; mod status; +mod wire; use config::Config; use gpui::{ diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs new file mode 100644 index 000000000000..dd86ded87283 --- /dev/null +++ b/apps/desktop-gpui/src/rpc.rs @@ -0,0 +1,285 @@ +//! Effect RPC over the WebSocket in its JSON serialization: request, ack, and response frames. +//! Shapes follow effect's `RpcMessage` (`FromClientEncoded`, `FromServerEncoded`). + +use crate::wire; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Deserializer, Serialize}; +use serde_json::{Value, json}; +use std::collections::HashSet; + +/// RpcMessage.constPing in the JSON serialization. +pub const PING_FRAME: &str = r#"{"_tag":"Ping"}"#; + +/// One RPC method: its tag and the generated wire types for its payload and success. +pub trait Method { + const TAG: &'static str; + type Payload: Serialize; + type Success: DeserializeOwned; +} + +/// `orchestration.subscribeShell`, a stream of shell snapshot and delta items. +pub struct SubscribeShell; + +impl Method for SubscribeShell { + const TAG: &'static str = "orchestration.subscribeShell"; + type Payload = wire::SubscribeShellPayload; + type Success = wire::ShellItem; +} + +/// Request ids are bigints on the wire, so they travel as strings. +fn id_text<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + match Value::deserialize(deserializer)? { + Value::String(text) => Ok(text), + Value::Number(number) => Ok(number.to_string()), + other => Err(serde::de::Error::custom(format!("bad request id: {other}"))), + } +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "_tag")] +enum ServerFrame { + Pong, + Chunk { + #[serde(rename = "requestId", deserialize_with = "id_text")] + request_id: String, + values: Vec, + }, + Exit { + #[serde(rename = "requestId", deserialize_with = "id_text")] + request_id: String, + exit: ExitFrame, + }, + Defect { + defect: Value, + }, + #[serde(other)] + Unknown, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "_tag")] +enum ExitFrame { + Success { value: Value }, + Failure { cause: Vec }, +} + +#[derive(Debug, Deserialize)] +#[serde(tag = "_tag")] +enum CauseFrame { + Fail { error: Value }, + Die { defect: Value }, + Interrupt, +} + +/// What a server frame means to the caller. +#[derive(Debug, PartialEq)] +pub enum Event { + Pong, + /// One element of a stream. The element is already acknowledged in `Reaction::send`. + Item { + request_id: String, + value: Value, + }, + /// The request finished successfully (a stream ended, or a unary call returned `value`). + Done { + request_id: String, + value: Value, + }, + /// The request failed with a typed error, a defect, or an interrupt. + Failed { + request_id: String, + message: String, + }, + /// A defect not tied to one request. + Defect(String), + /// Frame this client does not understand; ignored but proves the server is alive. + Ignored, +} + +#[derive(Debug, Default, PartialEq)] +pub struct Reaction { + /// Frames to write back (an Ack per Chunk). + pub send: Vec, + pub events: Vec, +} + +/// Request id bookkeeping for one socket. Create a new one per connection. +#[derive(Default)] +pub struct Session { + next_id: u64, + open: HashSet, +} + +impl Session { + pub fn new() -> Self { + Self::default() + } + + /// Returns the request id and the frame to send. + pub fn request(&mut self, payload: &M::Payload) -> Result<(String, String), String> { + let id = self.next_id.to_string(); + self.next_id += 1; + let frame = json!({ + "_tag": "Request", + "id": id, + "tag": M::TAG, + "payload": serde_json::to_value(payload).map_err(|error| error.to_string())?, + "headers": [], + }); + self.open.insert(id.clone()); + Ok((id, frame.to_string())) + } + + pub fn react(&mut self, text: &str) -> Reaction { + let mut reaction = Reaction::default(); + let Ok(frame) = serde_json::from_str::(text) else { + reaction.events.push(Event::Ignored); + return reaction; + }; + match frame { + ServerFrame::Pong => reaction.events.push(Event::Pong), + ServerFrame::Chunk { request_id, values } => { + // The server holds the next chunk until the previous one is acked. + reaction + .send + .push(json!({ "_tag": "Ack", "requestId": request_id }).to_string()); + for value in values { + reaction.events.push(Event::Item { + request_id: request_id.clone(), + value, + }); + } + } + ServerFrame::Exit { request_id, exit } => { + self.open.remove(&request_id); + reaction.events.push(match exit { + ExitFrame::Success { value } => Event::Done { request_id, value }, + ExitFrame::Failure { cause } => Event::Failed { + request_id, + message: describe_cause(&cause), + }, + }); + } + ServerFrame::Defect { defect } => { + reaction.events.push(Event::Defect(defect.to_string())) + } + ServerFrame::Unknown => reaction.events.push(Event::Ignored), + } + reaction + } +} + +fn describe_cause(cause: &[CauseFrame]) -> String { + let parts: Vec = cause + .iter() + .map(|entry| match entry { + CauseFrame::Fail { error } => format!("failed: {error}"), + CauseFrame::Die { defect } => format!("defect: {defect}"), + CauseFrame::Interrupt => "interrupted".to_owned(), + }) + .collect(); + if parts.is_empty() { + "request failed".to_owned() + } else { + parts.join("; ") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn request_frame_matches_the_effect_envelope() { + let mut session = Session::new(); + let payload = wire::SubscribeShellPayload { + request_completion_marker: Some(true), + ..Default::default() + }; + let (id, frame) = session.request::(&payload).expect("frame"); + let value: Value = serde_json::from_str(&frame).expect("json"); + assert_eq!(id, "0"); + assert_eq!( + value, + json!({ + "_tag": "Request", + "id": "0", + "tag": "orchestration.subscribeShell", + "payload": { "requestCompletionMarker": true }, + "headers": [], + }) + ); + let (second, _) = session.request::(&payload).expect("frame"); + assert_eq!(second, "1"); + } + + #[test] + fn chunk_is_acked_once_and_yields_each_value() { + let mut session = Session::new(); + let reaction = + session.react(r#"{"_tag":"Chunk","requestId":"3","values":[{"a":1},{"a":2}]}"#); + assert_eq!( + reaction.send, + vec![r#"{"_tag":"Ack","requestId":"3"}"#.to_owned()] + ); + assert_eq!( + reaction.events, + vec![ + Event::Item { + request_id: "3".into(), + value: json!({"a":1}) + }, + Event::Item { + request_id: "3".into(), + value: json!({"a":2}) + }, + ] + ); + } + + #[test] + fn numeric_request_ids_are_accepted() { + let reaction = Session::new().react(r#"{"_tag":"Chunk","requestId":7,"values":[1]}"#); + assert_eq!( + reaction.send, + vec![r#"{"_tag":"Ack","requestId":"7"}"#.to_owned()] + ); + } + + #[test] + fn exit_success_failure_defect_and_pong() { + let mut session = Session::new(); + assert_eq!( + session + .react(r#"{"_tag":"Exit","requestId":"1","exit":{"_tag":"Success","value":null}}"#) + .events, + vec![Event::Done { + request_id: "1".into(), + value: Value::Null + }] + ); + let failed = session.react( + r#"{"_tag":"Exit","requestId":"2","exit":{"_tag":"Failure","cause":[{"_tag":"Fail","error":{"_tag":"Nope"}},{"_tag":"Interrupt","fiberId":1}]}}"#, + ); + assert_eq!( + failed.events, + vec![Event::Failed { + request_id: "2".into(), + message: r#"failed: {"_tag":"Nope"}; interrupted"#.into() + }] + ); + assert!(matches!( + session.react(r#"{"_tag":"Defect","defect":"boom"}"#).events[0], + Event::Defect(_) + )); + assert_eq!( + session.react(r#"{"_tag":"Pong"}"#).events, + vec![Event::Pong] + ); + assert_eq!(session.react("not json").events, vec![Event::Ignored]); + assert_eq!( + session.react(r#"{"_tag":"Mystery"}"#).events, + vec![Event::Ignored] + ); + } +} diff --git a/apps/desktop-gpui/src/shell.rs b/apps/desktop-gpui/src/shell.rs new file mode 100644 index 000000000000..f68bd3c1ec60 --- /dev/null +++ b/apps/desktop-gpui/src/shell.rs @@ -0,0 +1,221 @@ +//! The shell read model built from `orchestration.subscribeShell`, mirroring +//! `packages/client-runtime/src/state/shellReducer.ts` for what this client shows. + +use crate::wire::generated::{ + OrchestrationSubscribeShellSuccess as Item, + OrchestrationSubscribeShellSuccessVariant4Location as Location, + OrchestrationSubscribeShellSuccessVariant5Location as RemovedLocation, +}; +use crate::wire::{Project, Thread}; +use std::collections::HashMap; + +#[derive(Default)] +pub struct ShellState { + projects: HashMap, + threads: HashMap, + sequence: i64, + seeded: bool, +} + +impl ShellState { + /// False until the first snapshot arrives; counts before that would read as "0". + pub fn is_seeded(&self) -> bool { + self.seeded + } + + pub fn project_count(&self) -> usize { + self.projects.len() + } + + /// Active threads only: the archive has its own query. + pub fn thread_count(&self) -> usize { + self.threads.len() + } + + /// Applies one stream item. Returns whether the counts or sync marker may have changed. + pub fn apply(&mut self, item: Item) -> bool { + match item { + // The completion marker carries nothing the counts need. + Item::Variant0 { .. } => false, + Item::Variant1 { snapshot, .. } => { + self.projects = snapshot + .projects + .0 + .into_iter() + .map(|p| (p.id.clone(), p)) + .collect(); + self.threads = snapshot + .threads + .0 + .into_iter() + .map(|t| (t.id.clone(), t)) + .collect(); + self.sequence = snapshot.snapshot_sequence; + self.seeded = true; + true + } + Item::Variant2 { + sequence, project, .. + } => { + if !self.advance(sequence) { + return false; + } + self.projects.insert(project.id.clone(), project); + true + } + Item::Variant3 { + sequence, + project_id, + .. + } => { + if !self.advance(sequence) { + return false; + } + self.projects.remove(&project_id); + true + } + Item::Variant4 { + sequence, + location, + thread, + .. + } => { + if !self.advance(sequence) { + return false; + } + match location { + Location::Active => { + self.threads.insert(thread.id.clone(), thread); + } + // Archived threads leave the normal shell; older servers still send these. + Location::Archive => { + self.threads.remove(&thread.id); + } + } + true + } + Item::Variant5 { + sequence, + thread_id, + location, + .. + } => { + if !self.advance(sequence) { + return false; + } + let _: RemovedLocation = location; + self.threads.remove(&thread_id); + true + } + } + } + + /// Events at or below the last applied sequence are stale. + fn advance(&mut self, sequence: i64) -> bool { + if sequence <= self.sequence { + return false; + } + self.sequence = sequence; + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + // Encoded with the contracts' own schema from packages/client-runtime's shell test fixtures. + const SNAPSHOT: &str = include_str!("../tests/fixtures/shell-snapshot.json"); + const PROJECT_UPDATED: &str = include_str!("../tests/fixtures/shell-project-updated.json"); + const THREAD_UPDATED: &str = include_str!("../tests/fixtures/shell-thread-updated.json"); + + fn item(text: &str) -> Item { + serde_json::from_str(text).expect("fixture decodes") + } + + fn with(text: &str, edit: impl FnOnce(&mut Value)) -> Item { + let mut value: Value = serde_json::from_str(text).expect("json"); + edit(&mut value); + serde_json::from_value(value).expect("edited fixture decodes") + } + + fn seeded() -> ShellState { + let mut state = ShellState::default(); + assert!(!state.is_seeded()); + state.apply(item(SNAPSHOT)); + state + } + + #[test] + fn snapshot_seeds_counts() { + let state = seeded(); + assert!(state.is_seeded()); + assert_eq!((state.project_count(), state.thread_count()), (1, 1)); + } + + #[test] + fn updates_upsert_and_removes_delete() { + let mut state = seeded(); + // A renamed existing project does not add a row. + assert!(state.apply(item(PROJECT_UPDATED))); + assert_eq!(state.project_count(), 1); + assert!(state.apply(item(THREAD_UPDATED))); + assert_eq!(state.thread_count(), 2); + + let removed_thread = serde_json::json!({ + "kind": "thread.removed", "sequence": 8, "location": "active", "threadId": "thread-two" + }); + assert!(state.apply(serde_json::from_value(removed_thread).unwrap())); + assert_eq!(state.thread_count(), 1); + + let removed_project = serde_json::json!({ + "kind": "project.removed", "sequence": 9, "projectId": "project-v2" + }); + assert!(state.apply(serde_json::from_value(removed_project).unwrap())); + assert_eq!(state.project_count(), 0); + } + + #[test] + fn new_project_id_adds_a_row() { + let mut state = seeded(); + state.apply(with(PROJECT_UPDATED, |value| { + value["project"]["id"] = "project-two".into() + })); + assert_eq!(state.project_count(), 2); + } + + #[test] + fn events_at_or_below_the_last_sequence_are_ignored() { + let mut state = seeded(); + for sequence in [4, 5] { + let stale = with(THREAD_UPDATED, |value| value["sequence"] = sequence.into()); + assert!(!state.apply(stale)); + } + assert_eq!(state.thread_count(), 1); + assert!(state.apply(item(THREAD_UPDATED))); + // Replaying the same sequence is stale now. + assert!(!state.apply(item(THREAD_UPDATED))); + assert_eq!(state.thread_count(), 2); + } + + #[test] + fn archive_located_thread_updates_leave_the_shell() { + let mut state = seeded(); + state.apply(item(THREAD_UPDATED)); + let archived = with(THREAD_UPDATED, |value| { + value["sequence"] = 8.into(); + value["location"] = "archive".into(); + }); + assert!(state.apply(archived)); + assert_eq!(state.thread_count(), 1); + } + + #[test] + fn a_later_snapshot_replaces_everything() { + let mut state = seeded(); + state.apply(item(THREAD_UPDATED)); + state.apply(item(SNAPSHOT)); + assert_eq!(state.thread_count(), 1); + } +} diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index 9e7cbd99470f..fb5526b2b966 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -6,17 +6,47 @@ pub enum Status { NotConnected, Starting, Connecting, - Connected(String), + /// Socket is up. Counts stay `None` until the first shell snapshot lands. + Connected { + label: String, + counts: Option, + }, Failed(String), } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Counts { + pub projects: usize, + pub threads: usize, +} + +fn plural(count: usize, noun: &str) -> String { + if count == 1 { + noun.to_owned() + } else { + format!("{noun}s") + } +} + impl fmt::Display for Status { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::NotConnected => f.write_str("Not connected"), Self::Starting => f.write_str("Starting server..."), Self::Connecting => f.write_str("Connecting..."), - Self::Connected(label) => write!(f, "Connected to {label}"), + Self::Connected { + label, + counts: None, + } => write!(f, "Connected to {label}"), + Self::Connected { + label, + counts: Some(Counts { projects, threads }), + } => write!( + f, + "Connected to {label} \u{b7} {projects} {} \u{b7} {threads} {}", + plural(*projects, "project"), + plural(*threads, "thread"), + ), Self::Failed(message) => f.write_str(message), } } @@ -28,3 +58,40 @@ pub fn publish(sender: &StatusSender, status: Status) { // Unbounded, so this only fails once the window is gone. let _ = sender.try_send(status); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn connected_text_includes_counts_once_known() { + let label = "Local".to_owned(); + let waiting = Status::Connected { + label: label.clone(), + counts: None, + }; + assert_eq!(waiting.to_string(), "Connected to Local"); + let known = Status::Connected { + label: label.clone(), + counts: Some(Counts { + projects: 2, + threads: 14, + }), + }; + assert_eq!( + known.to_string(), + "Connected to Local \u{b7} 2 projects \u{b7} 14 threads" + ); + let single = Status::Connected { + label, + counts: Some(Counts { + projects: 1, + threads: 1, + }), + }; + assert_eq!( + single.to_string(), + "Connected to Local \u{b7} 1 project \u{b7} 1 thread" + ); + } +} diff --git a/apps/desktop-gpui/src/wire/generated.rs b/apps/desktop-gpui/src/wire/generated.rs new file mode 100644 index 000000000000..829e3bf68a6d --- /dev/null +++ b/apps/desktop-gpui/src/wire/generated.rs @@ -0,0 +1,4058 @@ +// @generated by `cargo test --test wire_types` from packages/contracts/schema/rpc.schema.json. +// Do not edit. Regenerate with T3_UPDATE_GENERATED=1 cargo test --test wire_types. + +///`Author` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct Author { + #[serde( + rename = "avatarUrl", + deserialize_with = "::std::option::Option::deserialize" + )] + pub avatar_url: ::std::option::Option<::std::string::String>, + #[serde(rename = "isBot", skip_serializing_if = "::std::option::Option::is_none")] + pub is_bot: ::std::option::Option, + pub login: ::std::string::String, + #[serde(deserialize_with = "::std::option::Option::deserialize")] + pub name: ::std::option::Option<::std::string::String>, +} +///`Counts` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +#[serde(transparent)] +pub struct Counts(pub ::std::vec::Vec); +impl ::std::ops::Deref for Counts { + type Target = ::std::vec::Vec; + fn deref(&self) -> &::std::vec::Vec { + &self.0 + } +} +impl ::std::convert::From for ::std::vec::Vec { + fn from(value: Counts) -> Self { + value.0 + } +} +impl ::std::convert::From<::std::vec::Vec> for Counts { + fn from(value: ::std::vec::Vec) -> Self { + Self(value) + } +} +///`CountsItem` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct CountsItem { + pub count: i64, + #[serde(rename = "projectId")] + pub project_id: ::std::string::String, +} +///`DefaultProjectScripts` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +#[serde(transparent)] +pub struct DefaultProjectScripts(pub ::std::vec::Vec); +impl ::std::ops::Deref for DefaultProjectScripts { + type Target = ::std::vec::Vec; + fn deref(&self) -> &::std::vec::Vec { + &self.0 + } +} +impl ::std::convert::From +for ::std::vec::Vec { + fn from(value: DefaultProjectScripts) -> Self { + value.0 + } +} +impl ::std::convert::From<::std::vec::Vec> +for DefaultProjectScripts { + fn from(value: ::std::vec::Vec) -> Self { + Self(value) + } +} +///`DefaultProjectScriptsItem` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct DefaultProjectScriptsItem { + #[serde(rename = "async", skip_serializing_if = "::std::option::Option::is_none")] + pub async_: ::std::option::Option, + #[serde( + rename = "autoOpenPreview", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub auto_open_preview: ::std::option::Option, + pub command: ::std::string::String, + pub icon: DefaultProjectScriptsItemIcon, + pub id: ::std::string::String, + pub name: ::std::string::String, + #[serde( + rename = "previewUrl", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub preview_url: ::std::option::Option<::std::string::String>, + #[serde( + rename = "runOnSettle", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub run_on_settle: ::std::option::Option, + #[serde(rename = "runOnWorktreeCreate")] + pub run_on_worktree_create: bool, +} +///`DefaultProjectScriptsItemIcon` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum DefaultProjectScriptsItemIcon { + #[serde(rename = "play")] + Play, + #[serde(rename = "test")] + Test, + #[serde(rename = "lint")] + Lint, + #[serde(rename = "configure")] + Configure, + #[serde(rename = "build")] + Build, + #[serde(rename = "debug")] + Debug, +} +impl ::std::fmt::Display for DefaultProjectScriptsItemIcon { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Play => f.write_str("play"), + Self::Test => f.write_str("test"), + Self::Lint => f.write_str("lint"), + Self::Configure => f.write_str("configure"), + Self::Build => f.write_str("build"), + Self::Debug => f.write_str("debug"), + } + } +} +impl ::std::str::FromStr for DefaultProjectScriptsItemIcon { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "play" => Ok(Self::Play), + "test" => Ok(Self::Test), + "lint" => Ok(Self::Lint), + "configure" => Ok(Self::Configure), + "build" => Ok(Self::Build), + "debug" => Ok(Self::Debug), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for DefaultProjectScriptsItemIcon { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for DefaultProjectScriptsItemIcon { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`EndedWatch` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct EndedWatch { + #[serde( + rename = "checksAwaitedSince", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub checks_awaited_since: ::std::option::Option<::std::string::String>, + pub conflicting: bool, + #[serde(rename = "failedChecks")] + pub failed_checks: ::std::vec::Vec<::std::string::String>, + #[serde(rename = "headSha", deserialize_with = "::std::option::Option::deserialize")] + pub head_sha: ::std::option::Option<::std::string::String>, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub instructions: ::std::option::Option<::std::string::String>, + #[serde( + rename = "owedChecks", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub owed_checks: ::std::option::Option<::std::vec::Vec<::std::string::String>>, + pub passed: bool, + #[serde( + rename = "passedChecks", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub passed_checks: ::std::option::Option<::std::vec::Vec<::std::string::String>>, + #[serde(rename = "remarkIds")] + pub remark_ids: ::std::vec::Vec<::std::string::String>, + #[serde(rename = "remarksThrough")] + pub remarks_through: ::std::string::String, + #[serde(rename = "startedAt")] + pub started_at: ::std::string::String, + pub wakes: i64, +} +///`EnvironmentAuthorizationErrorEncoded` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct EnvironmentAuthorizationErrorEncoded { + pub message: ::std::string::String, + #[serde( + rename = "requiredPermission", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub required_permission: ::std::option::Option<::std::string::String>, + #[serde(rename = "requiredScope")] + pub required_scope: PermissionsEntry, + #[serde(rename = "_tag")] + pub tag: EnvironmentAuthorizationErrorEncodedTag, +} +///`EnvironmentAuthorizationErrorEncodedTag` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum EnvironmentAuthorizationErrorEncodedTag { + EnvironmentAuthorizationError, +} +impl ::std::fmt::Display for EnvironmentAuthorizationErrorEncodedTag { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::EnvironmentAuthorizationError => { + f.write_str("EnvironmentAuthorizationError") + } + } + } +} +impl ::std::str::FromStr for EnvironmentAuthorizationErrorEncodedTag { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "EnvironmentAuthorizationError" => Ok(Self::EnvironmentAuthorizationError), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for EnvironmentAuthorizationErrorEncodedTag { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for EnvironmentAuthorizationErrorEncodedTag { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ForkedFrom` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct ForkedFrom { + #[serde(rename = "providerThreadId")] + pub provider_thread_id: ::std::string::String, + #[serde( + rename = "providerTurnId", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub provider_turn_id: ::std::option::Option<::std::string::String>, + #[serde(rename = "type")] + pub type_: ForkedFromType, +} +///`ForkedFromType` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ForkedFromType { + #[serde(rename = "provider_thread")] + ProviderThread, +} +impl ::std::fmt::Display for ForkedFromType { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::ProviderThread => f.write_str("provider_thread"), + } + } +} +impl ::std::str::FromStr for ForkedFromType { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "provider_thread" => Ok(Self::ProviderThread), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ForkedFromType { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ForkedFromType { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`Goal` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct Goal { + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub checks: ::std::option::Option, + #[serde( + rename = "lastCheck", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub last_check: ::std::option::Option<::std::string::String>, + pub objective: ::std::string::String, + pub status: GoalStatus, + #[serde( + rename = "timeUsedSeconds", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub time_used_seconds: ::std::option::Option, + #[serde( + rename = "tokenBudget", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub token_budget: ::std::option::Option, + #[serde( + rename = "tokensUsed", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub tokens_used: ::std::option::Option, +} +///`GoalStatus` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum GoalStatus { + #[serde(rename = "active")] + Active, + #[serde(rename = "paused")] + Paused, + #[serde(rename = "blocked")] + Blocked, + #[serde(rename = "usage_limited")] + UsageLimited, + #[serde(rename = "budget_limited")] + BudgetLimited, + #[serde(rename = "complete")] + Complete, +} +impl ::std::fmt::Display for GoalStatus { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Active => f.write_str("active"), + Self::Paused => f.write_str("paused"), + Self::Blocked => f.write_str("blocked"), + Self::UsageLimited => f.write_str("usage_limited"), + Self::BudgetLimited => f.write_str("budget_limited"), + Self::Complete => f.write_str("complete"), + } + } +} +impl ::std::str::FromStr for GoalStatus { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "active" => Ok(Self::Active), + "paused" => Ok(Self::Paused), + "blocked" => Ok(Self::Blocked), + "usage_limited" => Ok(Self::UsageLimited), + "budget_limited" => Ok(Self::BudgetLimited), + "complete" => Ok(Self::Complete), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for GoalStatus { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for GoalStatus { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`LimitRecovery` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct LimitRecovery { + #[serde(rename = "autoResume")] + pub auto_resume: bool, + #[serde( + rename = "requestId", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub request_id: ::std::option::Option<::std::string::String>, + #[serde(rename = "resetAt")] + pub reset_at: ::std::string::String, + #[serde(rename = "runId")] + pub run_id: ::std::string::String, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub snooze: ::std::option::Option, +} +///`Lineage` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct Lineage { + #[serde( + rename = "parentThreadId", + deserialize_with = "::std::option::Option::deserialize" + )] + pub parent_thread_id: ::std::option::Option<::std::string::String>, + #[serde( + rename = "relationshipToParent", + deserialize_with = "::std::option::Option::deserialize" + )] + pub relationship_to_parent: ::std::option::Option, + #[serde(rename = "rootThreadId")] + pub root_thread_id: ::std::string::String, + #[serde(rename = "sideChat", skip_serializing_if = "::std::option::Option::is_none")] + pub side_chat: ::std::option::Option, +} +///`LineageRelationshipToParent` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum LineageRelationshipToParent { + #[serde(rename = "fork")] + Fork, + #[serde(rename = "subagent")] + Subagent, +} +impl ::std::fmt::Display for LineageRelationshipToParent { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Fork => f.write_str("fork"), + Self::Subagent => f.write_str("subagent"), + } + } +} +impl ::std::str::FromStr for LineageRelationshipToParent { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "fork" => Ok(Self::Fork), + "subagent" => Ok(Self::Subagent), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for LineageRelationshipToParent { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for LineageRelationshipToParent { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`LinkOrigin` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct LinkOrigin { + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub access: ::std::option::Option, + pub label: ::std::string::String, + #[serde(rename = "sessionId")] + pub session_id: ::std::string::String, +} +///`LinkOriginAccess` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum LinkOriginAccess { + #[serde(rename = "read-only")] + ReadOnly, + #[serde(rename = "approval-required")] + ApprovalRequired, + #[serde(rename = "auto-accept-edits")] + AutoAcceptEdits, + #[serde(rename = "auto")] + Auto, + #[serde(rename = "full-access")] + FullAccess, +} +impl ::std::fmt::Display for LinkOriginAccess { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::ReadOnly => f.write_str("read-only"), + Self::ApprovalRequired => f.write_str("approval-required"), + Self::AutoAcceptEdits => f.write_str("auto-accept-edits"), + Self::Auto => f.write_str("auto"), + Self::FullAccess => f.write_str("full-access"), + } + } +} +impl ::std::str::FromStr for LinkOriginAccess { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "read-only" => Ok(Self::ReadOnly), + "approval-required" => Ok(Self::ApprovalRequired), + "auto-accept-edits" => Ok(Self::AutoAcceptEdits), + "auto" => Ok(Self::Auto), + "full-access" => Ok(Self::FullAccess), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for LinkOriginAccess { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for LinkOriginAccess { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`LinkedPullRequest` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct LinkedPullRequest { + pub number: ::std::num::NonZeroU64, + #[serde(rename = "projectId")] + pub project_id: ::std::string::String, + pub repository: ::std::string::String, + pub url: ::std::string::String, +} +///`ModelSelection` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct ModelSelection { + #[serde( + rename = "instanceId", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub instance_id: ::std::option::Option<::serde_json::Value>, + pub model: ::serde_json::Value, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub options: ::std::option::Option<::serde_json::Value>, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub provider: ::std::option::Option<::serde_json::Value>, +} +///`Opens` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct Opens { + #[serde(rename = "openedAt")] + pub opened_at: f64, + pub picks: ::std::vec::Vec, + pub score: f64, +} +///`OpensPicksItem` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct OpensPicksItem { + pub at: f64, + pub query: OpensPicksItemQuery, +} +///`OpensPicksItemQuery` +#[derive(::serde::Serialize, Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[serde(transparent)] +pub struct OpensPicksItemQuery(::std::string::String); +impl ::std::ops::Deref for OpensPicksItemQuery { + type Target = ::std::string::String; + fn deref(&self) -> &::std::string::String { + &self.0 + } +} +impl ::std::convert::From for ::std::string::String { + fn from(value: OpensPicksItemQuery) -> Self { + value.0 + } +} +impl ::std::str::FromStr for OpensPicksItemQuery { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + ::std::convert::TryFrom::try_from(value) + } +} +impl ::std::convert::TryFrom<&str> for OpensPicksItemQuery { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + if value.chars().count() > 32usize { + return Err("longer than 32 characters".into()); + } + Ok(Self(value.to_string())) + } +} +impl ::std::convert::TryFrom<::std::string::String> for OpensPicksItemQuery { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + ::std::convert::TryFrom::try_from(value.as_str()) + } +} +impl<'de> ::serde::Deserialize<'de> for OpensPicksItemQuery { + fn deserialize(deserializer: D) -> ::std::result::Result + where + D: ::serde::Deserializer<'de>, + { + ::std::convert::TryFrom::try_from( + ::std::string::String::deserialize(deserializer)?, + ) + .map_err(|e: self::error::ConversionError| { + ::custom(e.to_string()) + }) + } +} +///`OrchestrationGetArchivedShellSnapshotError` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +#[serde(untagged)] +pub enum OrchestrationGetArchivedShellSnapshotError { + OrchestrationV2GetShellSnapshotErrorEncoded( + OrchestrationV2GetShellSnapshotErrorEncoded, + ), + EnvironmentAuthorizationErrorEncoded(EnvironmentAuthorizationErrorEncoded), +} +impl ::std::convert::From +for OrchestrationGetArchivedShellSnapshotError { + fn from(value: OrchestrationV2GetShellSnapshotErrorEncoded) -> Self { + Self::OrchestrationV2GetShellSnapshotErrorEncoded(value) + } +} +impl ::std::convert::From +for OrchestrationGetArchivedShellSnapshotError { + fn from(value: EnvironmentAuthorizationErrorEncoded) -> Self { + Self::EnvironmentAuthorizationErrorEncoded(value) + } +} +///`OrchestrationSubscribeShellPayload` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, Default)] +pub struct OrchestrationSubscribeShellPayload { + #[serde( + rename = "afterSequence", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub after_sequence: ::std::option::Option, + #[serde( + rename = "omitIdleSubagents", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub omit_idle_subagents: ::std::option::Option, + #[serde( + rename = "omitOlderSettled", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub omit_older_settled: ::std::option::Option, + #[serde( + rename = "requestCompletionMarker", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub request_completion_marker: ::std::option::Option, +} +///`OrchestrationSubscribeShellSuccess` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +#[serde(untagged)] +pub enum OrchestrationSubscribeShellSuccess { + Variant0 { kind: OrchestrationSubscribeShellSuccessVariant0Kind }, + Variant1 { + kind: OrchestrationSubscribeShellSuccessVariant1Kind, + #[serde( + rename = "resolvedRepositoryIdentityRoots", + default, + skip_serializing_if = "::std::vec::Vec::is_empty" + )] + resolved_repository_identity_roots: ::std::vec::Vec<::std::string::String>, + snapshot: OrchestrationSubscribeShellSuccessVariant1Snapshot, + }, + Variant2 { + kind: OrchestrationSubscribeShellSuccessVariant2Kind, + project: ProjectsEntry, + sequence: i64, + }, + Variant3 { + kind: OrchestrationSubscribeShellSuccessVariant3Kind, + #[serde(rename = "projectId")] + project_id: ::std::string::String, + sequence: i64, + }, + Variant4 { + kind: OrchestrationSubscribeShellSuccessVariant4Kind, + location: OrchestrationSubscribeShellSuccessVariant4Location, + sequence: i64, + thread: ThreadsEntry, + }, + Variant5 { + kind: OrchestrationSubscribeShellSuccessVariant5Kind, + location: OrchestrationSubscribeShellSuccessVariant5Location, + sequence: i64, + #[serde(rename = "threadId")] + thread_id: ::std::string::String, + }, +} +///`OrchestrationSubscribeShellSuccessVariant0Kind` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum OrchestrationSubscribeShellSuccessVariant0Kind { + #[serde(rename = "synchronized")] + Synchronized, +} +impl ::std::fmt::Display for OrchestrationSubscribeShellSuccessVariant0Kind { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Synchronized => f.write_str("synchronized"), + } + } +} +impl ::std::str::FromStr for OrchestrationSubscribeShellSuccessVariant0Kind { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "synchronized" => Ok(Self::Synchronized), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for OrchestrationSubscribeShellSuccessVariant0Kind { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for OrchestrationSubscribeShellSuccessVariant0Kind { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`OrchestrationSubscribeShellSuccessVariant1Kind` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum OrchestrationSubscribeShellSuccessVariant1Kind { + #[serde(rename = "snapshot")] + Snapshot, +} +impl ::std::fmt::Display for OrchestrationSubscribeShellSuccessVariant1Kind { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Snapshot => f.write_str("snapshot"), + } + } +} +impl ::std::str::FromStr for OrchestrationSubscribeShellSuccessVariant1Kind { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "snapshot" => Ok(Self::Snapshot), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for OrchestrationSubscribeShellSuccessVariant1Kind { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for OrchestrationSubscribeShellSuccessVariant1Kind { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`OrchestrationSubscribeShellSuccessVariant1Snapshot` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct OrchestrationSubscribeShellSuccessVariant1Snapshot { + #[serde(rename = "archivedThreads")] + pub archived_threads: Threads, + #[serde( + rename = "olderSettled", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub older_settled: ::std::option::Option< + OrchestrationSubscribeShellSuccessVariant1SnapshotOlderSettled, + >, + pub projects: Projects, + #[serde(rename = "schemaVersion")] + pub schema_version: ::std::num::NonZeroU64, + #[serde(rename = "snapshotSequence")] + pub snapshot_sequence: i64, + pub threads: Threads, +} +///`OrchestrationSubscribeShellSuccessVariant1SnapshotOlderSettled` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct OrchestrationSubscribeShellSuccessVariant1SnapshotOlderSettled { + #[serde(rename = "asOfSequence")] + pub as_of_sequence: i64, + pub before: ::std::string::String, + pub counts: Counts, +} +///`OrchestrationSubscribeShellSuccessVariant2Kind` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum OrchestrationSubscribeShellSuccessVariant2Kind { + #[serde(rename = "project.updated")] + ProjectUpdated, +} +impl ::std::fmt::Display for OrchestrationSubscribeShellSuccessVariant2Kind { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::ProjectUpdated => f.write_str("project.updated"), + } + } +} +impl ::std::str::FromStr for OrchestrationSubscribeShellSuccessVariant2Kind { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "project.updated" => Ok(Self::ProjectUpdated), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for OrchestrationSubscribeShellSuccessVariant2Kind { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for OrchestrationSubscribeShellSuccessVariant2Kind { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`OrchestrationSubscribeShellSuccessVariant3Kind` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum OrchestrationSubscribeShellSuccessVariant3Kind { + #[serde(rename = "project.removed")] + ProjectRemoved, +} +impl ::std::fmt::Display for OrchestrationSubscribeShellSuccessVariant3Kind { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::ProjectRemoved => f.write_str("project.removed"), + } + } +} +impl ::std::str::FromStr for OrchestrationSubscribeShellSuccessVariant3Kind { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "project.removed" => Ok(Self::ProjectRemoved), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for OrchestrationSubscribeShellSuccessVariant3Kind { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for OrchestrationSubscribeShellSuccessVariant3Kind { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`OrchestrationSubscribeShellSuccessVariant4Kind` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum OrchestrationSubscribeShellSuccessVariant4Kind { + #[serde(rename = "thread.updated")] + ThreadUpdated, +} +impl ::std::fmt::Display for OrchestrationSubscribeShellSuccessVariant4Kind { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::ThreadUpdated => f.write_str("thread.updated"), + } + } +} +impl ::std::str::FromStr for OrchestrationSubscribeShellSuccessVariant4Kind { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "thread.updated" => Ok(Self::ThreadUpdated), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for OrchestrationSubscribeShellSuccessVariant4Kind { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for OrchestrationSubscribeShellSuccessVariant4Kind { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`OrchestrationSubscribeShellSuccessVariant4Location` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum OrchestrationSubscribeShellSuccessVariant4Location { + #[serde(rename = "active")] + Active, + #[serde(rename = "archive")] + Archive, +} +impl ::std::fmt::Display for OrchestrationSubscribeShellSuccessVariant4Location { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Active => f.write_str("active"), + Self::Archive => f.write_str("archive"), + } + } +} +impl ::std::str::FromStr for OrchestrationSubscribeShellSuccessVariant4Location { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "active" => Ok(Self::Active), + "archive" => Ok(Self::Archive), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> +for OrchestrationSubscribeShellSuccessVariant4Location { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for OrchestrationSubscribeShellSuccessVariant4Location { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`OrchestrationSubscribeShellSuccessVariant5Kind` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum OrchestrationSubscribeShellSuccessVariant5Kind { + #[serde(rename = "thread.removed")] + ThreadRemoved, +} +impl ::std::fmt::Display for OrchestrationSubscribeShellSuccessVariant5Kind { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::ThreadRemoved => f.write_str("thread.removed"), + } + } +} +impl ::std::str::FromStr for OrchestrationSubscribeShellSuccessVariant5Kind { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "thread.removed" => Ok(Self::ThreadRemoved), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for OrchestrationSubscribeShellSuccessVariant5Kind { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for OrchestrationSubscribeShellSuccessVariant5Kind { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`OrchestrationSubscribeShellSuccessVariant5Location` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum OrchestrationSubscribeShellSuccessVariant5Location { + #[serde(rename = "active")] + Active, + #[serde(rename = "archive")] + Archive, +} +impl ::std::fmt::Display for OrchestrationSubscribeShellSuccessVariant5Location { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Active => f.write_str("active"), + Self::Archive => f.write_str("archive"), + } + } +} +impl ::std::str::FromStr for OrchestrationSubscribeShellSuccessVariant5Location { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "active" => Ok(Self::Active), + "archive" => Ok(Self::Archive), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> +for OrchestrationSubscribeShellSuccessVariant5Location { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for OrchestrationSubscribeShellSuccessVariant5Location { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`OrchestrationV2GetShellSnapshotErrorEncoded` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct OrchestrationV2GetShellSnapshotErrorEncoded { + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub cause: ::std::option::Option<::serde_json::Value>, + pub message: ::std::string::String, + #[serde(rename = "_tag")] + pub tag: OrchestrationV2GetShellSnapshotErrorEncodedTag, +} +///`OrchestrationV2GetShellSnapshotErrorEncodedTag` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum OrchestrationV2GetShellSnapshotErrorEncodedTag { + OrchestrationV2GetShellSnapshotError, +} +impl ::std::fmt::Display for OrchestrationV2GetShellSnapshotErrorEncodedTag { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::OrchestrationV2GetShellSnapshotError => { + f.write_str("OrchestrationV2GetShellSnapshotError") + } + } + } +} +impl ::std::str::FromStr for OrchestrationV2GetShellSnapshotErrorEncodedTag { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "OrchestrationV2GetShellSnapshotError" => { + Ok(Self::OrchestrationV2GetShellSnapshotError) + } + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for OrchestrationV2GetShellSnapshotErrorEncodedTag { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for OrchestrationV2GetShellSnapshotErrorEncodedTag { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`PendingBackgroundTasks` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +#[serde(transparent)] +pub struct PendingBackgroundTasks(pub ::std::vec::Vec); +impl ::std::ops::Deref for PendingBackgroundTasks { + type Target = ::std::vec::Vec; + fn deref(&self) -> &::std::vec::Vec { + &self.0 + } +} +impl ::std::convert::From +for ::std::vec::Vec { + fn from(value: PendingBackgroundTasks) -> Self { + value.0 + } +} +impl ::std::convert::From<::std::vec::Vec> +for PendingBackgroundTasks { + fn from(value: ::std::vec::Vec) -> Self { + Self(value) + } +} +///`PendingBackgroundTasksItem` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, Default)] +pub struct PendingBackgroundTasksItem { + #[serde(flatten, skip_serializing_if = "::std::option::Option::is_none")] + pub subtype_0: ::std::option::Option, + #[serde(flatten, skip_serializing_if = "::std::option::Option::is_none")] + pub subtype_1: ::std::option::Option, + #[serde(flatten, skip_serializing_if = "::std::option::Option::is_none")] + pub subtype_2: ::std::option::Option, + #[serde(flatten, skip_serializing_if = "::std::option::Option::is_none")] + pub subtype_3: ::std::option::Option, + #[serde(flatten, skip_serializing_if = "::std::option::Option::is_none")] + pub subtype_4: ::std::option::Option, +} +///`PendingBackgroundTasksItemSubtype0` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct PendingBackgroundTasksItemSubtype0 { + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub awaited: ::std::option::Option, + #[serde( + rename = "childThreadId", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub child_thread_id: ::std::option::Option<::std::string::String>, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub description: ::std::option::Option<::std::string::String>, + pub kind: PendingBackgroundTasksItemSubtype0Kind, + #[serde( + rename = "parentTaskId", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub parent_task_id: ::std::option::Option<::std::string::String>, + #[serde( + rename = "providerInstanceId", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub provider_instance_id: ::std::option::Option<::std::string::String>, + #[serde(rename = "taskId")] + pub task_id: ::std::string::String, +} +///`PendingBackgroundTasksItemSubtype0Kind` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum PendingBackgroundTasksItemSubtype0Kind { + #[serde(rename = "subagent")] + Subagent, +} +impl ::std::fmt::Display for PendingBackgroundTasksItemSubtype0Kind { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Subagent => f.write_str("subagent"), + } + } +} +impl ::std::str::FromStr for PendingBackgroundTasksItemSubtype0Kind { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "subagent" => Ok(Self::Subagent), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for PendingBackgroundTasksItemSubtype0Kind { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for PendingBackgroundTasksItemSubtype0Kind { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`PendingBackgroundTasksItemSubtype1` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct PendingBackgroundTasksItemSubtype1 { + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub awaited: ::std::option::Option, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub description: ::std::option::Option<::std::string::String>, + pub kind: PendingBackgroundTasksItemSubtype1Kind, + #[serde( + rename = "parentTaskId", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub parent_task_id: ::std::option::Option<::std::string::String>, + #[serde(rename = "taskId")] + pub task_id: ::std::string::String, +} +///`PendingBackgroundTasksItemSubtype1Kind` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum PendingBackgroundTasksItemSubtype1Kind { + #[serde(rename = "command")] + Command, +} +impl ::std::fmt::Display for PendingBackgroundTasksItemSubtype1Kind { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Command => f.write_str("command"), + } + } +} +impl ::std::str::FromStr for PendingBackgroundTasksItemSubtype1Kind { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "command" => Ok(Self::Command), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for PendingBackgroundTasksItemSubtype1Kind { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for PendingBackgroundTasksItemSubtype1Kind { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`PendingBackgroundTasksItemSubtype2` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct PendingBackgroundTasksItemSubtype2 { + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub awaited: ::std::option::Option, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub description: ::std::option::Option<::std::string::String>, + pub kind: PendingBackgroundTasksItemSubtype2Kind, + #[serde( + rename = "parentTaskId", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub parent_task_id: ::std::option::Option<::std::string::String>, + #[serde(rename = "taskId")] + pub task_id: ::std::string::String, +} +///`PendingBackgroundTasksItemSubtype2Kind` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum PendingBackgroundTasksItemSubtype2Kind { + #[serde(rename = "monitor")] + Monitor, +} +impl ::std::fmt::Display for PendingBackgroundTasksItemSubtype2Kind { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Monitor => f.write_str("monitor"), + } + } +} +impl ::std::str::FromStr for PendingBackgroundTasksItemSubtype2Kind { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "monitor" => Ok(Self::Monitor), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for PendingBackgroundTasksItemSubtype2Kind { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for PendingBackgroundTasksItemSubtype2Kind { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`PendingBackgroundTasksItemSubtype3` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct PendingBackgroundTasksItemSubtype3 { + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub awaited: ::std::option::Option, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub description: ::std::option::Option<::std::string::String>, + pub kind: PendingBackgroundTasksItemSubtype3Kind, + #[serde( + rename = "parentTaskId", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub parent_task_id: ::std::option::Option<::std::string::String>, + #[serde(rename = "taskId")] + pub task_id: ::std::string::String, +} +///`PendingBackgroundTasksItemSubtype3Kind` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum PendingBackgroundTasksItemSubtype3Kind { + #[serde(rename = "background_task")] + BackgroundTask, +} +impl ::std::fmt::Display for PendingBackgroundTasksItemSubtype3Kind { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::BackgroundTask => f.write_str("background_task"), + } + } +} +impl ::std::str::FromStr for PendingBackgroundTasksItemSubtype3Kind { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "background_task" => Ok(Self::BackgroundTask), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for PendingBackgroundTasksItemSubtype3Kind { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for PendingBackgroundTasksItemSubtype3Kind { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`PendingBackgroundTasksItemSubtype4` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct PendingBackgroundTasksItemSubtype4 { + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub awaited: ::std::option::Option, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub description: ::std::option::Option<::std::string::String>, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub kind: ::std::option::Option<::std::string::String>, + #[serde( + rename = "parentTaskId", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub parent_task_id: ::std::option::Option<::std::string::String>, + #[serde(rename = "taskId")] + pub task_id: ::std::string::String, +} +///`PermissionsEntry` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum PermissionsEntry { + #[serde(rename = "orchestration:read")] + OrchestrationRead, + #[serde(rename = "orchestration:operate")] + OrchestrationOperate, + #[serde(rename = "settings:write")] + SettingsWrite, + #[serde(rename = "providers:manage")] + ProvidersManage, + #[serde(rename = "environment:maintain")] + EnvironmentMaintain, + #[serde(rename = "preview:operate")] + PreviewOperate, + #[serde(rename = "diagnostics:read")] + DiagnosticsRead, + #[serde(rename = "terminal:read")] + TerminalRead, + #[serde(rename = "terminal:operate")] + TerminalOperate, + #[serde(rename = "filesystem:read")] + FilesystemRead, + #[serde(rename = "filesystem:write")] + FilesystemWrite, + #[serde(rename = "review:write")] + ReviewWrite, + #[serde(rename = "source-control:write")] + SourceControlWrite, + #[serde(rename = "access:read")] + AccessRead, + #[serde(rename = "access:write")] + AccessWrite, + #[serde(rename = "relay:read")] + RelayRead, + #[serde(rename = "relay:write")] + RelayWrite, +} +impl ::std::fmt::Display for PermissionsEntry { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::OrchestrationRead => f.write_str("orchestration:read"), + Self::OrchestrationOperate => f.write_str("orchestration:operate"), + Self::SettingsWrite => f.write_str("settings:write"), + Self::ProvidersManage => f.write_str("providers:manage"), + Self::EnvironmentMaintain => f.write_str("environment:maintain"), + Self::PreviewOperate => f.write_str("preview:operate"), + Self::DiagnosticsRead => f.write_str("diagnostics:read"), + Self::TerminalRead => f.write_str("terminal:read"), + Self::TerminalOperate => f.write_str("terminal:operate"), + Self::FilesystemRead => f.write_str("filesystem:read"), + Self::FilesystemWrite => f.write_str("filesystem:write"), + Self::ReviewWrite => f.write_str("review:write"), + Self::SourceControlWrite => f.write_str("source-control:write"), + Self::AccessRead => f.write_str("access:read"), + Self::AccessWrite => f.write_str("access:write"), + Self::RelayRead => f.write_str("relay:read"), + Self::RelayWrite => f.write_str("relay:write"), + } + } +} +impl ::std::str::FromStr for PermissionsEntry { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "orchestration:read" => Ok(Self::OrchestrationRead), + "orchestration:operate" => Ok(Self::OrchestrationOperate), + "settings:write" => Ok(Self::SettingsWrite), + "providers:manage" => Ok(Self::ProvidersManage), + "environment:maintain" => Ok(Self::EnvironmentMaintain), + "preview:operate" => Ok(Self::PreviewOperate), + "diagnostics:read" => Ok(Self::DiagnosticsRead), + "terminal:read" => Ok(Self::TerminalRead), + "terminal:operate" => Ok(Self::TerminalOperate), + "filesystem:read" => Ok(Self::FilesystemRead), + "filesystem:write" => Ok(Self::FilesystemWrite), + "review:write" => Ok(Self::ReviewWrite), + "source-control:write" => Ok(Self::SourceControlWrite), + "access:read" => Ok(Self::AccessRead), + "access:write" => Ok(Self::AccessWrite), + "relay:read" => Ok(Self::RelayRead), + "relay:write" => Ok(Self::RelayWrite), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for PermissionsEntry { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for PermissionsEntry { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ProjectIcon` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct ProjectIcon { + pub color: ProjectIconColor, + pub kind: ProjectIconKind, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub monogram: ::std::option::Option<::std::string::String>, + #[serde( + rename = "monogramText", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub monogram_text: ::std::option::Option<::std::string::String>, + pub name: ::std::string::String, +} +///`ProjectIcon2` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct ProjectIcon2 { + pub color: ProjectIcon2Color, + pub kind: ProjectIcon2Kind, + pub text: ::std::string::String, +} +///`ProjectIcon2Color` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ProjectIcon2Color { + #[serde(rename = "gray")] + Gray, + #[serde(rename = "red")] + Red, + #[serde(rename = "orange")] + Orange, + #[serde(rename = "amber")] + Amber, + #[serde(rename = "yellow")] + Yellow, + #[serde(rename = "lime")] + Lime, + #[serde(rename = "green")] + Green, + #[serde(rename = "emerald")] + Emerald, + #[serde(rename = "teal")] + Teal, + #[serde(rename = "cyan")] + Cyan, + #[serde(rename = "sky")] + Sky, + #[serde(rename = "blue")] + Blue, + #[serde(rename = "indigo")] + Indigo, + #[serde(rename = "violet")] + Violet, + #[serde(rename = "purple")] + Purple, + #[serde(rename = "fuchsia")] + Fuchsia, + #[serde(rename = "pink")] + Pink, + #[serde(rename = "rose")] + Rose, +} +impl ::std::fmt::Display for ProjectIcon2Color { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Gray => f.write_str("gray"), + Self::Red => f.write_str("red"), + Self::Orange => f.write_str("orange"), + Self::Amber => f.write_str("amber"), + Self::Yellow => f.write_str("yellow"), + Self::Lime => f.write_str("lime"), + Self::Green => f.write_str("green"), + Self::Emerald => f.write_str("emerald"), + Self::Teal => f.write_str("teal"), + Self::Cyan => f.write_str("cyan"), + Self::Sky => f.write_str("sky"), + Self::Blue => f.write_str("blue"), + Self::Indigo => f.write_str("indigo"), + Self::Violet => f.write_str("violet"), + Self::Purple => f.write_str("purple"), + Self::Fuchsia => f.write_str("fuchsia"), + Self::Pink => f.write_str("pink"), + Self::Rose => f.write_str("rose"), + } + } +} +impl ::std::str::FromStr for ProjectIcon2Color { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "gray" => Ok(Self::Gray), + "red" => Ok(Self::Red), + "orange" => Ok(Self::Orange), + "amber" => Ok(Self::Amber), + "yellow" => Ok(Self::Yellow), + "lime" => Ok(Self::Lime), + "green" => Ok(Self::Green), + "emerald" => Ok(Self::Emerald), + "teal" => Ok(Self::Teal), + "cyan" => Ok(Self::Cyan), + "sky" => Ok(Self::Sky), + "blue" => Ok(Self::Blue), + "indigo" => Ok(Self::Indigo), + "violet" => Ok(Self::Violet), + "purple" => Ok(Self::Purple), + "fuchsia" => Ok(Self::Fuchsia), + "pink" => Ok(Self::Pink), + "rose" => Ok(Self::Rose), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ProjectIcon2Color { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ProjectIcon2Color { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ProjectIcon2Kind` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ProjectIcon2Kind { + #[serde(rename = "monogram")] + Monogram, +} +impl ::std::fmt::Display for ProjectIcon2Kind { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Monogram => f.write_str("monogram"), + } + } +} +impl ::std::str::FromStr for ProjectIcon2Kind { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "monogram" => Ok(Self::Monogram), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ProjectIcon2Kind { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ProjectIcon2Kind { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ProjectIconColor` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ProjectIconColor { + #[serde(rename = "gray")] + Gray, + #[serde(rename = "red")] + Red, + #[serde(rename = "orange")] + Orange, + #[serde(rename = "amber")] + Amber, + #[serde(rename = "yellow")] + Yellow, + #[serde(rename = "lime")] + Lime, + #[serde(rename = "green")] + Green, + #[serde(rename = "emerald")] + Emerald, + #[serde(rename = "teal")] + Teal, + #[serde(rename = "cyan")] + Cyan, + #[serde(rename = "sky")] + Sky, + #[serde(rename = "blue")] + Blue, + #[serde(rename = "indigo")] + Indigo, + #[serde(rename = "violet")] + Violet, + #[serde(rename = "purple")] + Purple, + #[serde(rename = "fuchsia")] + Fuchsia, + #[serde(rename = "pink")] + Pink, + #[serde(rename = "rose")] + Rose, +} +impl ::std::fmt::Display for ProjectIconColor { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Gray => f.write_str("gray"), + Self::Red => f.write_str("red"), + Self::Orange => f.write_str("orange"), + Self::Amber => f.write_str("amber"), + Self::Yellow => f.write_str("yellow"), + Self::Lime => f.write_str("lime"), + Self::Green => f.write_str("green"), + Self::Emerald => f.write_str("emerald"), + Self::Teal => f.write_str("teal"), + Self::Cyan => f.write_str("cyan"), + Self::Sky => f.write_str("sky"), + Self::Blue => f.write_str("blue"), + Self::Indigo => f.write_str("indigo"), + Self::Violet => f.write_str("violet"), + Self::Purple => f.write_str("purple"), + Self::Fuchsia => f.write_str("fuchsia"), + Self::Pink => f.write_str("pink"), + Self::Rose => f.write_str("rose"), + } + } +} +impl ::std::str::FromStr for ProjectIconColor { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "gray" => Ok(Self::Gray), + "red" => Ok(Self::Red), + "orange" => Ok(Self::Orange), + "amber" => Ok(Self::Amber), + "yellow" => Ok(Self::Yellow), + "lime" => Ok(Self::Lime), + "green" => Ok(Self::Green), + "emerald" => Ok(Self::Emerald), + "teal" => Ok(Self::Teal), + "cyan" => Ok(Self::Cyan), + "sky" => Ok(Self::Sky), + "blue" => Ok(Self::Blue), + "indigo" => Ok(Self::Indigo), + "violet" => Ok(Self::Violet), + "purple" => Ok(Self::Purple), + "fuchsia" => Ok(Self::Fuchsia), + "pink" => Ok(Self::Pink), + "rose" => Ok(Self::Rose), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ProjectIconColor { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ProjectIconColor { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ProjectIconKind` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ProjectIconKind { + #[serde(rename = "lucide")] + Lucide, +} +impl ::std::fmt::Display for ProjectIconKind { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Lucide => f.write_str("lucide"), + } + } +} +impl ::std::str::FromStr for ProjectIconKind { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "lucide" => Ok(Self::Lucide), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ProjectIconKind { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ProjectIconKind { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`Projects` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +#[serde(transparent)] +pub struct Projects(pub ::std::vec::Vec); +impl ::std::ops::Deref for Projects { + type Target = ::std::vec::Vec; + fn deref(&self) -> &::std::vec::Vec { + &self.0 + } +} +impl ::std::convert::From for ::std::vec::Vec { + fn from(value: Projects) -> Self { + value.0 + } +} +impl ::std::convert::From<::std::vec::Vec> for Projects { + fn from(value: ::std::vec::Vec) -> Self { + Self(value) + } +} +///`ProjectsEntry` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct ProjectsEntry { + #[serde( + rename = "archivedAt", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub archived_at: ::std::option::Option<::std::string::String>, + #[serde(rename = "autoPull", skip_serializing_if = "::std::option::Option::is_none")] + pub auto_pull: ::std::option::Option, + #[serde(rename = "createdAt")] + pub created_at: ::std::string::String, + #[serde( + rename = "defaultModelSelection", + deserialize_with = "::std::option::Option::deserialize" + )] + pub default_model_selection: ::std::option::Option, + #[serde( + rename = "defaultThreadEnvMode", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub default_thread_env_mode: ::std::option::Option< + ProjectsEntryDefaultThreadEnvMode, + >, + #[serde( + rename = "faviconPath", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub favicon_path: ::std::option::Option<::std::string::String>, + pub id: ::std::string::String, + #[serde(rename = "pinnedAt", skip_serializing_if = "::std::option::Option::is_none")] + pub pinned_at: ::std::option::Option<::std::string::String>, + #[serde( + rename = "projectIcon", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub project_icon: ::std::option::Option<::serde_json::Value>, + #[serde( + rename = "repositoryIdentity", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub repository_identity: ::std::option::Option, + pub scripts: DefaultProjectScripts, + pub title: ::std::string::String, + #[serde(rename = "updatedAt")] + pub updated_at: ::std::string::String, + #[serde(rename = "workspaceRoot")] + pub workspace_root: ::std::string::String, +} +///`ProjectsEntryDefaultThreadEnvMode` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ProjectsEntryDefaultThreadEnvMode { + #[serde(rename = "local")] + Local, + #[serde(rename = "worktree")] + Worktree, +} +impl ::std::fmt::Display for ProjectsEntryDefaultThreadEnvMode { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Local => f.write_str("local"), + Self::Worktree => f.write_str("worktree"), + } + } +} +impl ::std::str::FromStr for ProjectsEntryDefaultThreadEnvMode { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "local" => Ok(Self::Local), + "worktree" => Ok(Self::Worktree), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ProjectsEntryDefaultThreadEnvMode { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for ProjectsEntryDefaultThreadEnvMode { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`PullRequests` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +#[serde(transparent)] +pub struct PullRequests(pub ::std::vec::Vec); +impl ::std::ops::Deref for PullRequests { + type Target = ::std::vec::Vec; + fn deref(&self) -> &::std::vec::Vec { + &self.0 + } +} +impl ::std::convert::From for ::std::vec::Vec { + fn from(value: PullRequests) -> Self { + value.0 + } +} +impl ::std::convert::From<::std::vec::Vec> for PullRequests { + fn from(value: ::std::vec::Vec) -> Self { + Self(value) + } +} +///`PullRequestsItem` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct PullRequestsItem { + #[serde( + rename = "endedWatch", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub ended_watch: ::std::option::Option, + pub host: ::std::string::String, + #[serde(rename = "linkedAt")] + pub linked_at: ::std::string::String, + pub number: ::std::num::NonZeroU64, + pub repository: ::std::string::String, + #[serde( + rename = "repositoryWebUrl", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub repository_web_url: ::std::option::Option<::std::string::String>, + #[serde(deserialize_with = "::std::option::Option::deserialize")] + pub snapshot: ::std::option::Option, + pub source: PullRequestsItemSource, + #[serde(deserialize_with = "::std::option::Option::deserialize")] + pub stack: ::std::option::Option, + pub url: ::std::string::String, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub watch: ::std::option::Option, +} +///`PullRequestsItemSource` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum PullRequestsItemSource { + #[serde(rename = "manual")] + Manual, + #[serde(rename = "created")] + Created, + #[serde(rename = "agent")] + Agent, + #[serde(rename = "stack")] + Stack, + #[serde(rename = "stack-dismissed")] + StackDismissed, +} +impl ::std::fmt::Display for PullRequestsItemSource { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Manual => f.write_str("manual"), + Self::Created => f.write_str("created"), + Self::Agent => f.write_str("agent"), + Self::Stack => f.write_str("stack"), + Self::StackDismissed => f.write_str("stack-dismissed"), + } + } +} +impl ::std::str::FromStr for PullRequestsItemSource { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "manual" => Ok(Self::Manual), + "created" => Ok(Self::Created), + "agent" => Ok(Self::Agent), + "stack" => Ok(Self::Stack), + "stack-dismissed" => Ok(Self::StackDismissed), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for PullRequestsItemSource { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for PullRequestsItemSource { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`RepositoryIdentity` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct RepositoryIdentity { + #[serde(rename = "canonicalKey")] + pub canonical_key: ::std::string::String, + #[serde( + rename = "displayName", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub display_name: ::std::option::Option<::std::string::String>, + pub locator: RepositoryIdentityLocator, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub name: ::std::option::Option<::std::string::String>, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub origin: ::std::option::Option, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub owner: ::std::option::Option<::std::string::String>, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub provider: ::std::option::Option<::std::string::String>, + #[serde(rename = "rootPath", skip_serializing_if = "::std::option::Option::is_none")] + pub root_path: ::std::option::Option<::std::string::String>, + #[serde(rename = "webUrl", skip_serializing_if = "::std::option::Option::is_none")] + pub web_url: ::std::option::Option<::std::string::String>, +} +///`RepositoryIdentityLocator` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct RepositoryIdentityLocator { + #[serde(rename = "remoteName")] + pub remote_name: ::std::string::String, + #[serde(rename = "remoteUrl")] + pub remote_url: ::std::string::String, + pub source: RepositoryIdentityLocatorSource, +} +///`RepositoryIdentityLocatorSource` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum RepositoryIdentityLocatorSource { + #[serde(rename = "git-remote")] + GitRemote, +} +impl ::std::fmt::Display for RepositoryIdentityLocatorSource { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::GitRemote => f.write_str("git-remote"), + } + } +} +impl ::std::str::FromStr for RepositoryIdentityLocatorSource { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "git-remote" => Ok(Self::GitRemote), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for RepositoryIdentityLocatorSource { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for RepositoryIdentityLocatorSource { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`RepositoryIdentityOrigin` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct RepositoryIdentityOrigin { + #[serde(rename = "canonicalKey")] + pub canonical_key: ::std::string::String, + #[serde( + rename = "displayName", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub display_name: ::std::option::Option<::std::string::String>, + #[serde( + rename = "remoteUrl", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub remote_url: ::std::option::Option<::std::string::String>, +} +///`Snapshot` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct Snapshot { + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub additions: ::std::option::Option, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub author: ::std::option::Option, + #[serde(rename = "baseBranch")] + pub base_branch: ::std::string::String, + #[serde( + rename = "changedFiles", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub changed_files: ::std::option::Option, + #[serde( + rename = "checksState", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub checks_state: ::std::option::Option, + #[serde(rename = "closedAt", skip_serializing_if = "::std::option::Option::is_none")] + pub closed_at: ::std::option::Option<::std::string::String>, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub deletions: ::std::option::Option, + #[serde(rename = "headBranch")] + pub head_branch: ::std::string::String, + #[serde(rename = "isDraft")] + pub is_draft: bool, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub mergeability: ::std::option::Option, + #[serde(rename = "mergedAt", skip_serializing_if = "::std::option::Option::is_none")] + pub merged_at: ::std::option::Option<::std::string::String>, + #[serde( + rename = "reviewDecision", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub review_decision: ::std::option::Option, + pub state: SnapshotState, + #[serde(rename = "syncedAt")] + pub synced_at: ::std::string::String, + pub title: ::std::string::String, + #[serde( + rename = "updatedAt", + deserialize_with = "::std::option::Option::deserialize" + )] + pub updated_at: ::std::option::Option<::std::string::String>, +} +///`SnapshotChecksState` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum SnapshotChecksState { + #[serde(rename = "passing")] + Passing, + #[serde(rename = "failing")] + Failing, + #[serde(rename = "pending")] + Pending, +} +impl ::std::fmt::Display for SnapshotChecksState { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Passing => f.write_str("passing"), + Self::Failing => f.write_str("failing"), + Self::Pending => f.write_str("pending"), + } + } +} +impl ::std::str::FromStr for SnapshotChecksState { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "passing" => Ok(Self::Passing), + "failing" => Ok(Self::Failing), + "pending" => Ok(Self::Pending), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for SnapshotChecksState { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for SnapshotChecksState { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`SnapshotMergeability` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum SnapshotMergeability { + #[serde(rename = "mergeable")] + Mergeable, + #[serde(rename = "conflicting")] + Conflicting, + #[serde(rename = "unknown")] + Unknown, +} +impl ::std::fmt::Display for SnapshotMergeability { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Mergeable => f.write_str("mergeable"), + Self::Conflicting => f.write_str("conflicting"), + Self::Unknown => f.write_str("unknown"), + } + } +} +impl ::std::str::FromStr for SnapshotMergeability { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "mergeable" => Ok(Self::Mergeable), + "conflicting" => Ok(Self::Conflicting), + "unknown" => Ok(Self::Unknown), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for SnapshotMergeability { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for SnapshotMergeability { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`SnapshotReviewDecision` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum SnapshotReviewDecision { + #[serde(rename = "approved")] + Approved, + #[serde(rename = "changes-requested")] + ChangesRequested, + #[serde(rename = "review-required")] + ReviewRequired, +} +impl ::std::fmt::Display for SnapshotReviewDecision { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Approved => f.write_str("approved"), + Self::ChangesRequested => f.write_str("changes-requested"), + Self::ReviewRequired => f.write_str("review-required"), + } + } +} +impl ::std::str::FromStr for SnapshotReviewDecision { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "approved" => Ok(Self::Approved), + "changes-requested" => Ok(Self::ChangesRequested), + "review-required" => Ok(Self::ReviewRequired), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for SnapshotReviewDecision { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for SnapshotReviewDecision { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`SnapshotState` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum SnapshotState { + #[serde(rename = "open")] + Open, + #[serde(rename = "closed")] + Closed, + #[serde(rename = "merged")] + Merged, +} +impl ::std::fmt::Display for SnapshotState { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Open => f.write_str("open"), + Self::Closed => f.write_str("closed"), + Self::Merged => f.write_str("merged"), + } + } +} +impl ::std::str::FromStr for SnapshotState { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "open" => Ok(Self::Open), + "closed" => Ok(Self::Closed), + "merged" => Ok(Self::Merged), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for SnapshotState { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for SnapshotState { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`Stack` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct Stack { + pub base: ::std::string::String, + pub id: ::std::string::String, + pub kind: StackKind, + pub layers: ::std::vec::Vec, + pub number: ::std::num::NonZeroU64, + pub url: ::std::string::String, +} +///`StackKind` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum StackKind { + #[serde(rename = "native")] + Native, +} +impl ::std::fmt::Display for StackKind { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Native => f.write_str("native"), + } + } +} +impl ::std::str::FromStr for StackKind { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "native" => Ok(Self::Native), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for StackKind { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for StackKind { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`StackLayersItem` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct StackLayersItem { + #[serde(rename = "headBranch")] + pub head_branch: ::std::string::String, + pub number: ::std::num::NonZeroU64, + pub state: StackLayersItemState, +} +///`StackLayersItemState` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum StackLayersItemState { + #[serde(rename = "open")] + Open, + #[serde(rename = "closed")] + Closed, + #[serde(rename = "merged")] + Merged, +} +impl ::std::fmt::Display for StackLayersItemState { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Open => f.write_str("open"), + Self::Closed => f.write_str("closed"), + Self::Merged => f.write_str("merged"), + } + } +} +impl ::std::str::FromStr for StackLayersItemState { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "open" => Ok(Self::Open), + "closed" => Ok(Self::Closed), + "merged" => Ok(Self::Merged), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for StackLayersItemState { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for StackLayersItemState { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`Threads` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +#[serde(transparent)] +pub struct Threads(pub ::std::vec::Vec); +impl ::std::ops::Deref for Threads { + type Target = ::std::vec::Vec; + fn deref(&self) -> &::std::vec::Vec { + &self.0 + } +} +impl ::std::convert::From for ::std::vec::Vec { + fn from(value: Threads) -> Self { + value.0 + } +} +impl ::std::convert::From<::std::vec::Vec> for Threads { + fn from(value: ::std::vec::Vec) -> Self { + Self(value) + } +} +///`ThreadsEntry` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct ThreadsEntry { + #[serde( + rename = "activeOrderKey", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub active_order_key: ::std::option::Option<::std::string::String>, + #[serde( + rename = "activeProviderThreadId", + deserialize_with = "::std::option::Option::deserialize" + )] + pub active_provider_thread_id: ::std::option::Option<::std::string::String>, + #[serde( + rename = "activeRunId", + deserialize_with = "::std::option::Option::deserialize" + )] + pub active_run_id: ::std::option::Option<::std::string::String>, + #[serde( + rename = "activityRunStartedAt", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub activity_run_started_at: ::std::option::Option<::std::string::String>, + #[serde( + rename = "activityRunStatus", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub activity_run_status: ::std::option::Option, + #[serde( + rename = "archivedAt", + deserialize_with = "::std::option::Option::deserialize" + )] + pub archived_at: ::std::option::Option<::std::string::String>, + #[serde( + rename = "autoSettleDisabledAt", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub auto_settle_disabled_at: ::std::option::Option<::std::string::String>, + #[serde( + rename = "blockedOnThreadWait", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub blocked_on_thread_wait: ::std::option::Option, + #[serde(deserialize_with = "::std::option::Option::deserialize")] + pub branch: ::std::option::Option<::std::string::String>, + #[serde( + rename = "branchPullRequest", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub branch_pull_request: ::std::option::Option, + #[serde(rename = "createdAt")] + pub created_at: ::std::string::String, + #[serde(rename = "createdBy")] + pub created_by: ThreadsEntryCreatedBy, + #[serde(rename = "creationSource")] + pub creation_source: ThreadsEntryCreationSource, + #[serde( + rename = "delegatedFrom", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub delegated_from: ::std::option::Option, + #[serde( + rename = "deletedAt", + deserialize_with = "::std::option::Option::deserialize" + )] + pub deleted_at: ::std::option::Option<::std::string::String>, + #[serde(rename = "forkedFrom")] + pub forked_from: ThreadsEntryForkedFrom, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub goal: ::std::option::Option, + #[serde(rename = "hasActionableProposedPlan")] + pub has_actionable_proposed_plan: bool, + #[serde( + rename = "historyOrigin", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub history_origin: ::std::option::Option, + pub id: ::std::string::String, + #[serde(rename = "interactionMode")] + pub interaction_mode: ThreadsEntryInteractionMode, + #[serde(rename = "itemCount")] + pub item_count: i64, + #[serde( + rename = "lastError", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub last_error: ::std::option::Option<::std::string::String>, + #[serde( + rename = "lastErrorClass", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub last_error_class: ::std::option::Option, + #[serde( + rename = "lastErrorCode", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub last_error_code: ::std::option::Option<::std::string::String>, + #[serde( + rename = "lastVisitedAt", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub last_visited_at: ::std::option::Option<::std::string::String>, + #[serde( + rename = "latestRunCompletedAt", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub latest_run_completed_at: ::std::option::Option<::std::string::String>, + #[serde( + rename = "latestRunId", + deserialize_with = "::std::option::Option::deserialize" + )] + pub latest_run_id: ::std::option::Option<::std::string::String>, + #[serde( + rename = "latestRunRequestedAt", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub latest_run_requested_at: ::std::option::Option<::std::string::String>, + #[serde( + rename = "latestRunStartedAt", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub latest_run_started_at: ::std::option::Option<::std::string::String>, + #[serde( + rename = "latestRunStopRequested", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub latest_run_stop_requested: ::std::option::Option, + #[serde( + rename = "latestUserAuthoredMessageAt", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub latest_user_authored_message_at: ::std::option::Option<::std::string::String>, + #[serde( + rename = "latestUserMessageAt", + deserialize_with = "::std::option::Option::deserialize" + )] + pub latest_user_message_at: ::std::option::Option<::std::string::String>, + #[serde( + rename = "latestVisibleMessage", + deserialize_with = "::std::option::Option::deserialize" + )] + pub latest_visible_message: ::std::option::Option, + #[serde( + rename = "limitRecovery", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub limit_recovery: ::std::option::Option, + pub lineage: Lineage, + #[serde( + rename = "linkOrigin", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub link_origin: ::std::option::Option, + #[serde( + rename = "linkedPullRequest", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub linked_pull_request: ::std::option::Option, + #[serde(rename = "modelSelection")] + pub model_selection: ModelSelection, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub opens: ::std::option::Option, + #[serde( + rename = "pendingBackgroundTasks", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub pending_background_tasks: ::std::option::Option, + #[serde( + rename = "pendingGuestMessageCount", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub pending_guest_message_count: ::std::option::Option, + #[serde( + rename = "pendingRuntimeRequest", + deserialize_with = "::std::option::Option::deserialize" + )] + pub pending_runtime_request: ::std::option::Option< + ThreadsEntryPendingRuntimeRequest, + >, + #[serde( + rename = "pinOrderKey", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub pin_order_key: ::std::option::Option<::std::string::String>, + #[serde(rename = "pinnedAt", skip_serializing_if = "::std::option::Option::is_none")] + pub pinned_at: ::std::option::Option<::std::string::String>, + #[serde(rename = "projectId")] + pub project_id: ::std::string::String, + #[serde( + rename = "providerInstanceHistory", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub provider_instance_history: ::std::option::Option< + ::std::vec::Vec<::std::string::String>, + >, + #[serde(rename = "providerInstanceId")] + pub provider_instance_id: ::std::string::String, + #[serde( + rename = "pullRequests", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub pull_requests: ::std::option::Option, + #[serde(rename = "runtimeMode")] + pub runtime_mode: ThreadsEntryRuntimeMode, + #[serde( + rename = "settledAt", + deserialize_with = "::std::option::Option::deserialize" + )] + pub settled_at: ::std::option::Option<::std::string::String>, + #[serde( + rename = "settledOverride", + deserialize_with = "::std::option::Option::deserialize" + )] + pub settled_override: ::std::option::Option, + #[serde( + rename = "snoozedAt", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub snoozed_at: ::std::option::Option<::std::string::String>, + #[serde( + rename = "snoozedUntil", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub snoozed_until: ::std::option::Option<::std::string::String>, + pub status: ThreadsEntryStatus, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub tags: ::std::option::Option<::std::vec::Vec<::std::string::String>>, + pub title: ::std::string::String, + #[serde( + rename = "titleRegeneration", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub title_regeneration: ::std::option::Option, + #[serde( + rename = "unsettledAt", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub unsettled_at: ::std::option::Option<::std::string::String>, + #[serde(rename = "updatedAt")] + pub updated_at: ::std::string::String, + #[serde( + rename = "usageLimitResetAt", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub usage_limit_reset_at: ::std::option::Option<::std::string::String>, + #[serde(rename = "visibleItemCount")] + pub visible_item_count: i64, + #[serde( + rename = "worktreePath", + deserialize_with = "::std::option::Option::deserialize" + )] + pub worktree_path: ::std::option::Option<::std::string::String>, +} +///`ThreadsEntryActivityRunStatus` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ThreadsEntryActivityRunStatus { + #[serde(rename = "preparing")] + Preparing, + #[serde(rename = "starting")] + Starting, + #[serde(rename = "running")] + Running, + #[serde(rename = "waiting")] + Waiting, +} +impl ::std::fmt::Display for ThreadsEntryActivityRunStatus { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Preparing => f.write_str("preparing"), + Self::Starting => f.write_str("starting"), + Self::Running => f.write_str("running"), + Self::Waiting => f.write_str("waiting"), + } + } +} +impl ::std::str::FromStr for ThreadsEntryActivityRunStatus { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "preparing" => Ok(Self::Preparing), + "starting" => Ok(Self::Starting), + "running" => Ok(Self::Running), + "waiting" => Ok(Self::Waiting), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ThreadsEntryActivityRunStatus { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ThreadsEntryActivityRunStatus { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ThreadsEntryCreatedBy` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ThreadsEntryCreatedBy { + #[serde(rename = "user")] + User, + #[serde(rename = "agent")] + Agent, + #[serde(rename = "system")] + System, +} +impl ::std::fmt::Display for ThreadsEntryCreatedBy { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::User => f.write_str("user"), + Self::Agent => f.write_str("agent"), + Self::System => f.write_str("system"), + } + } +} +impl ::std::str::FromStr for ThreadsEntryCreatedBy { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "user" => Ok(Self::User), + "agent" => Ok(Self::Agent), + "system" => Ok(Self::System), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ThreadsEntryCreatedBy { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ThreadsEntryCreatedBy { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ThreadsEntryCreationSource` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ThreadsEntryCreationSource { + #[serde(rename = "web")] + Web, + #[serde(rename = "mobile")] + Mobile, + #[serde(rename = "mcp")] + Mcp, + #[serde(rename = "provider")] + Provider, + #[serde(rename = "server")] + Server, +} +impl ::std::fmt::Display for ThreadsEntryCreationSource { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Web => f.write_str("web"), + Self::Mobile => f.write_str("mobile"), + Self::Mcp => f.write_str("mcp"), + Self::Provider => f.write_str("provider"), + Self::Server => f.write_str("server"), + } + } +} +impl ::std::str::FromStr for ThreadsEntryCreationSource { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "web" => Ok(Self::Web), + "mobile" => Ok(Self::Mobile), + "mcp" => Ok(Self::Mcp), + "provider" => Ok(Self::Provider), + "server" => Ok(Self::Server), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ThreadsEntryCreationSource { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ThreadsEntryCreationSource { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ThreadsEntryDelegatedFrom` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct ThreadsEntryDelegatedFrom { + #[serde(rename = "environmentId")] + pub environment_id: ::std::string::String, + #[serde(rename = "threadId")] + pub thread_id: ::std::string::String, + pub title: ::std::string::String, +} +///`ThreadsEntryForkedFrom` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +#[serde(untagged)] +pub enum ThreadsEntryForkedFrom { + Variant0 { + #[serde(rename = "runId")] + run_id: ::std::string::String, + #[serde(rename = "threadId")] + thread_id: ::std::string::String, + #[serde(rename = "type")] + type_: ThreadsEntryForkedFromVariant0Type, + }, + Variant1 { + #[serde(rename = "nodeId")] + node_id: ::std::string::String, + #[serde(rename = "type")] + type_: ThreadsEntryForkedFromVariant1Type, + }, + Variant2(ForkedFrom), + Variant3, +} +impl ::std::convert::From for ThreadsEntryForkedFrom { + fn from(value: ForkedFrom) -> Self { + Self::Variant2(value) + } +} +///`ThreadsEntryForkedFromVariant0Type` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ThreadsEntryForkedFromVariant0Type { + #[serde(rename = "run")] + Run, +} +impl ::std::fmt::Display for ThreadsEntryForkedFromVariant0Type { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Run => f.write_str("run"), + } + } +} +impl ::std::str::FromStr for ThreadsEntryForkedFromVariant0Type { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "run" => Ok(Self::Run), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ThreadsEntryForkedFromVariant0Type { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for ThreadsEntryForkedFromVariant0Type { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ThreadsEntryForkedFromVariant1Type` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ThreadsEntryForkedFromVariant1Type { + #[serde(rename = "node")] + Node, +} +impl ::std::fmt::Display for ThreadsEntryForkedFromVariant1Type { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Node => f.write_str("node"), + } + } +} +impl ::std::str::FromStr for ThreadsEntryForkedFromVariant1Type { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "node" => Ok(Self::Node), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ThreadsEntryForkedFromVariant1Type { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for ThreadsEntryForkedFromVariant1Type { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ThreadsEntryHistoryOrigin` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ThreadsEntryHistoryOrigin { + #[serde(rename = "native")] + Native, + #[serde(rename = "v1_import")] + V1Import, +} +impl ::std::fmt::Display for ThreadsEntryHistoryOrigin { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Native => f.write_str("native"), + Self::V1Import => f.write_str("v1_import"), + } + } +} +impl ::std::str::FromStr for ThreadsEntryHistoryOrigin { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "native" => Ok(Self::Native), + "v1_import" => Ok(Self::V1Import), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ThreadsEntryHistoryOrigin { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ThreadsEntryHistoryOrigin { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ThreadsEntryInteractionMode` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ThreadsEntryInteractionMode { + #[serde(rename = "default")] + Default, + #[serde(rename = "plan")] + Plan, +} +impl ::std::fmt::Display for ThreadsEntryInteractionMode { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Default => f.write_str("default"), + Self::Plan => f.write_str("plan"), + } + } +} +impl ::std::str::FromStr for ThreadsEntryInteractionMode { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "default" => Ok(Self::Default), + "plan" => Ok(Self::Plan), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ThreadsEntryInteractionMode { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ThreadsEntryInteractionMode { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ThreadsEntryLastErrorClass` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ThreadsEntryLastErrorClass { + #[serde(rename = "usage_limit")] + UsageLimit, + #[serde(rename = "provider_error")] + ProviderError, + #[serde(rename = "transport_error")] + TransportError, + #[serde(rename = "permission_error")] + PermissionError, + #[serde(rename = "validation_error")] + ValidationError, + #[serde(rename = "unknown")] + Unknown, +} +impl ::std::fmt::Display for ThreadsEntryLastErrorClass { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::UsageLimit => f.write_str("usage_limit"), + Self::ProviderError => f.write_str("provider_error"), + Self::TransportError => f.write_str("transport_error"), + Self::PermissionError => f.write_str("permission_error"), + Self::ValidationError => f.write_str("validation_error"), + Self::Unknown => f.write_str("unknown"), + } + } +} +impl ::std::str::FromStr for ThreadsEntryLastErrorClass { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "usage_limit" => Ok(Self::UsageLimit), + "provider_error" => Ok(Self::ProviderError), + "transport_error" => Ok(Self::TransportError), + "permission_error" => Ok(Self::PermissionError), + "validation_error" => Ok(Self::ValidationError), + "unknown" => Ok(Self::Unknown), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ThreadsEntryLastErrorClass { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ThreadsEntryLastErrorClass { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ThreadsEntryLatestVisibleMessage` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct ThreadsEntryLatestVisibleMessage { + pub id: ::std::string::String, + pub role: ThreadsEntryLatestVisibleMessageRole, + pub text: ::std::string::String, + #[serde(rename = "updatedAt")] + pub updated_at: ::std::string::String, +} +///`ThreadsEntryLatestVisibleMessageRole` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ThreadsEntryLatestVisibleMessageRole { + #[serde(rename = "user")] + User, + #[serde(rename = "assistant")] + Assistant, + #[serde(rename = "system")] + System, +} +impl ::std::fmt::Display for ThreadsEntryLatestVisibleMessageRole { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::User => f.write_str("user"), + Self::Assistant => f.write_str("assistant"), + Self::System => f.write_str("system"), + } + } +} +impl ::std::str::FromStr for ThreadsEntryLatestVisibleMessageRole { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "user" => Ok(Self::User), + "assistant" => Ok(Self::Assistant), + "system" => Ok(Self::System), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ThreadsEntryLatestVisibleMessageRole { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for ThreadsEntryLatestVisibleMessageRole { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ThreadsEntryPendingRuntimeRequest` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct ThreadsEntryPendingRuntimeRequest { + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub blocking: ::std::option::Option, + #[serde(rename = "createdAt")] + pub created_at: ::std::string::String, + pub id: ::std::string::String, + pub kind: ThreadsEntryPendingRuntimeRequestKind, +} +///`ThreadsEntryPendingRuntimeRequestKind` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ThreadsEntryPendingRuntimeRequestKind { + #[serde(rename = "command")] + Command, + #[serde(rename = "file-read")] + FileRead, + #[serde(rename = "file-change")] + FileChange, + #[serde(rename = "mcp-elicitation")] + McpElicitation, + #[serde(rename = "permission")] + Permission, + #[serde(rename = "dynamic_tool_call")] + DynamicToolCall, + #[serde(rename = "user_input")] + UserInput, + #[serde(rename = "auth_refresh")] + AuthRefresh, +} +impl ::std::fmt::Display for ThreadsEntryPendingRuntimeRequestKind { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Command => f.write_str("command"), + Self::FileRead => f.write_str("file-read"), + Self::FileChange => f.write_str("file-change"), + Self::McpElicitation => f.write_str("mcp-elicitation"), + Self::Permission => f.write_str("permission"), + Self::DynamicToolCall => f.write_str("dynamic_tool_call"), + Self::UserInput => f.write_str("user_input"), + Self::AuthRefresh => f.write_str("auth_refresh"), + } + } +} +impl ::std::str::FromStr for ThreadsEntryPendingRuntimeRequestKind { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "command" => Ok(Self::Command), + "file-read" => Ok(Self::FileRead), + "file-change" => Ok(Self::FileChange), + "mcp-elicitation" => Ok(Self::McpElicitation), + "permission" => Ok(Self::Permission), + "dynamic_tool_call" => Ok(Self::DynamicToolCall), + "user_input" => Ok(Self::UserInput), + "auth_refresh" => Ok(Self::AuthRefresh), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ThreadsEntryPendingRuntimeRequestKind { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for ThreadsEntryPendingRuntimeRequestKind { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ThreadsEntryRuntimeMode` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ThreadsEntryRuntimeMode { + #[serde(rename = "approval-required")] + ApprovalRequired, + #[serde(rename = "auto-accept-edits")] + AutoAcceptEdits, + #[serde(rename = "auto")] + Auto, + #[serde(rename = "full-access")] + FullAccess, +} +impl ::std::fmt::Display for ThreadsEntryRuntimeMode { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::ApprovalRequired => f.write_str("approval-required"), + Self::AutoAcceptEdits => f.write_str("auto-accept-edits"), + Self::Auto => f.write_str("auto"), + Self::FullAccess => f.write_str("full-access"), + } + } +} +impl ::std::str::FromStr for ThreadsEntryRuntimeMode { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "approval-required" => Ok(Self::ApprovalRequired), + "auto-accept-edits" => Ok(Self::AutoAcceptEdits), + "auto" => Ok(Self::Auto), + "full-access" => Ok(Self::FullAccess), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ThreadsEntryRuntimeMode { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ThreadsEntryRuntimeMode { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ThreadsEntrySettledOverride` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ThreadsEntrySettledOverride { + #[serde(rename = "settled")] + Settled, + #[serde(rename = "active")] + Active, +} +impl ::std::fmt::Display for ThreadsEntrySettledOverride { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Settled => f.write_str("settled"), + Self::Active => f.write_str("active"), + } + } +} +impl ::std::str::FromStr for ThreadsEntrySettledOverride { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "settled" => Ok(Self::Settled), + "active" => Ok(Self::Active), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ThreadsEntrySettledOverride { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ThreadsEntrySettledOverride { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ThreadsEntryStatus` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ThreadsEntryStatus { + #[serde(rename = "idle")] + Idle, + #[serde(rename = "preparing")] + Preparing, + #[serde(rename = "queued")] + Queued, + #[serde(rename = "starting")] + Starting, + #[serde(rename = "running")] + Running, + #[serde(rename = "waiting")] + Waiting, + #[serde(rename = "completed")] + Completed, + #[serde(rename = "interrupted")] + Interrupted, + #[serde(rename = "failed")] + Failed, + #[serde(rename = "cancelled")] + Cancelled, + #[serde(rename = "rolled_back")] + RolledBack, +} +impl ::std::fmt::Display for ThreadsEntryStatus { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Idle => f.write_str("idle"), + Self::Preparing => f.write_str("preparing"), + Self::Queued => f.write_str("queued"), + Self::Starting => f.write_str("starting"), + Self::Running => f.write_str("running"), + Self::Waiting => f.write_str("waiting"), + Self::Completed => f.write_str("completed"), + Self::Interrupted => f.write_str("interrupted"), + Self::Failed => f.write_str("failed"), + Self::Cancelled => f.write_str("cancelled"), + Self::RolledBack => f.write_str("rolled_back"), + } + } +} +impl ::std::str::FromStr for ThreadsEntryStatus { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "idle" => Ok(Self::Idle), + "preparing" => Ok(Self::Preparing), + "queued" => Ok(Self::Queued), + "starting" => Ok(Self::Starting), + "running" => Ok(Self::Running), + "waiting" => Ok(Self::Waiting), + "completed" => Ok(Self::Completed), + "interrupted" => Ok(Self::Interrupted), + "failed" => Ok(Self::Failed), + "cancelled" => Ok(Self::Cancelled), + "rolled_back" => Ok(Self::RolledBack), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ThreadsEntryStatus { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ThreadsEntryStatus { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ThreadsEntryTitleRegeneration` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct ThreadsEntryTitleRegeneration { + #[serde(rename = "requestId")] + pub request_id: ::std::string::String, + #[serde(rename = "startedAt")] + pub started_at: ::std::string::String, +} +///`WireRoot` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +#[serde(transparent)] +pub struct WireRoot(pub ::serde_json::Value); +impl ::std::ops::Deref for WireRoot { + type Target = ::serde_json::Value; + fn deref(&self) -> &::serde_json::Value { + &self.0 + } +} +impl ::std::convert::From for ::serde_json::Value { + fn from(value: WireRoot) -> Self { + value.0 + } +} +impl ::std::convert::From<::serde_json::Value> for WireRoot { + fn from(value: ::serde_json::Value) -> Self { + Self(value) + } +} +/// Error types. +pub mod error { + /// Error from a `TryFrom` or `FromStr` implementation. + pub struct ConversionError(::std::borrow::Cow<'static, str>); + impl ::std::error::Error for ConversionError {} + impl ::std::fmt::Display for ConversionError { + fn fmt( + &self, + f: &mut ::std::fmt::Formatter<'_>, + ) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Display::fmt(&self.0, f) + } + } + impl ::std::fmt::Debug for ConversionError { + fn fmt( + &self, + f: &mut ::std::fmt::Formatter<'_>, + ) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Debug::fmt(&self.0, f) + } + } + impl From<&'static str> for ConversionError { + fn from(value: &'static str) -> Self { + Self(value.into()) + } + } + impl From for ConversionError { + fn from(value: String) -> Self { + Self(value.into()) + } + } +} diff --git a/apps/desktop-gpui/src/wire/mod.rs b/apps/desktop-gpui/src/wire/mod.rs new file mode 100644 index 000000000000..f1c9f2d703b5 --- /dev/null +++ b/apps/desktop-gpui/src/wire/mod.rs @@ -0,0 +1,11 @@ +//! Wire types generated from the TypeScript contracts (`packages/contracts/schema/rpc.schema.json`). + +#[allow(clippy::all, dead_code, unused_imports, unused_qualifications)] +#[rustfmt::skip] +pub mod generated; + +pub use generated::{ + OrchestrationSubscribeShellPayload as SubscribeShellPayload, + OrchestrationSubscribeShellSuccess as ShellItem, ProjectsEntry as Project, + ThreadsEntry as Thread, +}; diff --git a/apps/desktop-gpui/tests/fixtures/shell-project-updated.json b/apps/desktop-gpui/tests/fixtures/shell-project-updated.json new file mode 100644 index 000000000000..fc288d4f48a7 --- /dev/null +++ b/apps/desktop-gpui/tests/fixtures/shell-project-updated.json @@ -0,0 +1,14 @@ +{ + "kind": "project.updated", + "sequence": 6, + "project": { + "id": "project-v2", + "title": "Renamed", + "workspaceRoot": "/workspace/project", + "repositoryIdentity": null, + "defaultModelSelection": null, + "scripts": [], + "createdAt": "2026-06-20T00:00:00.000Z", + "updatedAt": "2026-06-20T00:00:00.000Z" + } +} diff --git a/apps/desktop-gpui/tests/fixtures/shell-snapshot.json b/apps/desktop-gpui/tests/fixtures/shell-snapshot.json new file mode 100644 index 000000000000..5042b8d8056b --- /dev/null +++ b/apps/desktop-gpui/tests/fixtures/shell-snapshot.json @@ -0,0 +1,61 @@ +{ + "kind": "snapshot", + "snapshot": { + "schemaVersion": 1, + "snapshotSequence": 5, + "threads": [ + { + "createdBy": "user", + "creationSource": "web", + "id": "thread-v2", + "projectId": "project-v2", + "title": "Thread", + "providerInstanceId": "codex", + "modelSelection": { + "instanceId": "codex", + "model": "gpt-5.4" + }, + "runtimeMode": "full-access", + "interactionMode": "default", + "branch": null, + "worktreePath": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "thread-v2" + }, + "forkedFrom": null, + "activeProviderThreadId": null, + "latestRunId": null, + "activeRunId": null, + "status": "idle", + "pendingRuntimeRequest": null, + "latestVisibleMessage": null, + "latestUserMessageAt": null, + "hasActionableProposedPlan": false, + "itemCount": 0, + "visibleItemCount": 0, + "createdAt": "2026-06-20T00:00:00.000Z", + "updatedAt": "2026-06-20T00:00:00.000Z", + "archivedAt": null, + "settledOverride": null, + "settledAt": null, + "lastVisitedAt": null, + "deletedAt": null + } + ], + "archivedThreads": [], + "projects": [ + { + "id": "project-v2", + "title": "Project", + "workspaceRoot": "/workspace/project", + "repositoryIdentity": null, + "defaultModelSelection": null, + "scripts": [], + "createdAt": "2026-06-20T00:00:00.000Z", + "updatedAt": "2026-06-20T00:00:00.000Z" + } + ] + } +} diff --git a/apps/desktop-gpui/tests/fixtures/shell-thread-updated.json b/apps/desktop-gpui/tests/fixtures/shell-thread-updated.json new file mode 100644 index 000000000000..e6fe26908aac --- /dev/null +++ b/apps/desktop-gpui/tests/fixtures/shell-thread-updated.json @@ -0,0 +1,44 @@ +{ + "kind": "thread.updated", + "sequence": 7, + "location": "active", + "thread": { + "createdBy": "user", + "creationSource": "web", + "id": "thread-two", + "projectId": "project-v2", + "title": "Thread", + "providerInstanceId": "codex", + "modelSelection": { + "instanceId": "codex", + "model": "gpt-5.4" + }, + "runtimeMode": "full-access", + "interactionMode": "default", + "branch": null, + "worktreePath": null, + "lineage": { + "parentThreadId": null, + "relationshipToParent": null, + "rootThreadId": "thread-v2" + }, + "forkedFrom": null, + "activeProviderThreadId": null, + "latestRunId": null, + "activeRunId": null, + "status": "idle", + "pendingRuntimeRequest": null, + "latestVisibleMessage": null, + "latestUserMessageAt": null, + "hasActionableProposedPlan": false, + "itemCount": 0, + "visibleItemCount": 0, + "createdAt": "2026-06-20T00:00:00.000Z", + "updatedAt": "2026-06-20T00:00:00.000Z", + "archivedAt": null, + "settledOverride": null, + "settledAt": null, + "lastVisitedAt": null, + "deletedAt": null + } +} diff --git a/apps/desktop-gpui/tests/wire_types.rs b/apps/desktop-gpui/tests/wire_types.rs new file mode 100644 index 000000000000..88f87240ef78 --- /dev/null +++ b/apps/desktop-gpui/tests/wire_types.rs @@ -0,0 +1,133 @@ +//! Regenerates `src/wire/generated.rs` from `packages/contracts/schema/rpc.schema.json` and fails +//! when the committed file differs. Run with `T3_UPDATE_GENERATED=1 cargo test --test wire_types` +//! to rewrite it. The schema is checked against the TypeScript contracts by +//! `vp run --filter @t3tools/contracts generate --check`. + +use serde_json::{Map, Value}; +use std::collections::BTreeSet; +use std::path::PathBuf; + +/// RPC methods whose payload, success and error types are generated. Add a method here when the +/// client starts calling it. +const METHODS: &[&str] = &["orchestration.subscribeShell"]; + +/// Properties typify cannot represent, kept as `serde_json::Value`: (definition, property). +/// Gaps are listed in the README. +const OPAQUE: &[(&str, &str)] = &[("ProjectsEntry", "projectIcon")]; + +const HEADER: &str = "// @generated by `cargo test --test wire_types` from packages/contracts/schema/rpc.schema.json.\n// Do not edit. Regenerate with T3_UPDATE_GENERATED=1 cargo test --test wire_types.\n\n"; + +fn manifest_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) +} + +fn collect_refs(node: &Value, out: &mut BTreeSet) { + match node { + Value::Object(map) => { + for (key, value) in map { + match (key.as_str(), value) { + ("$ref", Value::String(target)) => { + if let Some(name) = target.strip_prefix("#/$defs/") { + out.insert(name.to_owned()); + } + } + _ => collect_refs(value, out), + } + } + } + Value::Array(items) => items.iter().for_each(|item| collect_refs(item, out)), + _ => {} + } +} + +/// schemars 0.8 reads `#/definitions/`, draft 2020-12 writes `#/$defs/`. +fn retarget(node: &mut Value) { + match node { + Value::Object(map) => { + for (key, value) in map.iter_mut() { + if key == "$ref" { + if let Value::String(target) = value + && let Some(name) = target.strip_prefix("#/$defs/") + { + *target = format!("#/definitions/{name}"); + } + } else { + retarget(value); + } + } + } + Value::Array(items) => items.iter_mut().for_each(retarget), + _ => {} + } +} + +fn subset() -> Value { + let path = manifest_dir().join("../../packages/contracts/schema/rpc.schema.json"); + let document: Value = + serde_json::from_str(&std::fs::read_to_string(&path).expect("read rpc.schema.json")) + .expect("parse rpc.schema.json"); + let all = document["$defs"].as_object().expect("$defs"); + let mut pending: Vec = Vec::new(); + for method in METHODS { + let entry = &document["methods"][*method]; + for part in ["payload", "success", "error"] { + let target = entry[part]["$ref"] + .as_str() + .unwrap_or_else(|| panic!("{method} {part} is not a $ref in the schema index")); + pending.push(target.trim_start_matches("#/$defs/").to_owned()); + } + } + let mut needed = BTreeSet::new(); + while let Some(name) = pending.pop() { + if needed.insert(name.clone()) { + let mut refs = BTreeSet::new(); + collect_refs(&all[&name], &mut refs); + pending.extend(refs); + } + } + let definitions: Map = needed + .into_iter() + .map(|name| { + let mut body = all[&name].clone(); + for (def, property) in OPAQUE { + if *def == name { + body["properties"][*property] = serde_json::json!({}); + } + } + retarget(&mut body); + (name, body) + }) + .collect(); + serde_json::json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "WireRoot", + "definitions": definitions, + }) +} + +fn generate() -> String { + let root: schemars::schema::RootSchema = + serde_json::from_value(subset()).expect("schema subset is valid JSON Schema"); + let mut space = typify::TypeSpace::new(&typify::TypeSpaceSettings::default()); + space + .add_root_schema(root) + .expect("typify accepts the schema subset"); + let tokens = space.to_stream().expect("typify emits tokens"); + let file = syn::parse2(tokens).expect("generated code parses"); + format!("{HEADER}{}", prettyplease::unparse(&file)) +} + +#[test] +fn generated_wire_types_match_the_schema() { + let path = manifest_dir().join("src/wire/generated.rs"); + let fresh = generate(); + if std::env::var_os("T3_UPDATE_GENERATED").is_some() { + std::fs::write(&path, &fresh).expect("write generated.rs"); + return; + } + let current = std::fs::read_to_string(&path).unwrap_or_default(); + assert!( + current == fresh, + "src/wire/generated.rs is stale. Run T3_UPDATE_GENERATED=1 cargo test --test wire_types" + ); +} diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 180b65a91686..31a1a65e9928 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -21,6 +21,7 @@ } }, "scripts": { + "generate": "node scripts/export-rpc-json-schema.ts", "typecheck": "effect-tsgo tsrs --noEmit", "test": "vp test run --config ../../vite.config.ts --dir ." }, diff --git a/packages/contracts/schema/rpc.schema.json b/packages/contracts/schema/rpc.schema.json new file mode 100644 index 000000000000..24a560f4233b --- /dev/null +++ b/packages/contracts/schema/rpc.schema.json @@ -0,0 +1,52466 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "T3 Code WebSocket RPC (encoded)", + "$defs": { + "AcpRegistryOperationErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AcpRegistryOperationError" + ] + }, + "reason": { + "type": "string", + "enum": [ + "agent_not_configured", + "agent_not_found", + "archive_invalid", + "authentication_failed", + "checksum_mismatch", + "download_failed", + "install_failed", + "instance_not_found", + "logout_unsupported", + "logout_failed", + "probe_failed", + "project_not_found", + "registry_unavailable", + "runner_unavailable", + "session_import_failed", + "session_delete_unsupported", + "session_delete_failed", + "session_list_unsupported", + "session_resume_unsupported", + "providers_unsupported", + "providers_list_failed", + "provider_configuration_failed", + "unsupported_distribution", + "unsupported_platform" + ] + }, + "message": { + "type": "string" + }, + "authMethods": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 1024 + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "agent", + "env_var", + "terminal" + ] + }, + "command": { + "type": "string", + "maxLength": 2048 + }, + "envVarNames": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 16 + }, + "link": { + "type": "string", + "maxLength": 2048 + } + }, + "required": [ + "id", + "name", + "description", + "type" + ], + "additionalProperties": true + }, + "maxItems": 32 + }, + "authAction": { + "$ref": "#/$defs/Action" + }, + "cause": {} + }, + "required": [ + "_tag", + "reason", + "message" + ], + "additionalProperties": true + }, + "Action": { + "type": "object", + "properties": { + "elicitationId": { + "type": "string" + }, + "url": { + "type": "string", + "maxLength": 2048 + }, + "message": { + "type": "string", + "maxLength": 1024 + }, + "createdAt": { + "type": "string", + "maxLength": 128 + }, + "expiresAt": { + "type": "string", + "maxLength": 128 + } + }, + "required": [ + "elicitationId", + "url", + "message" + ], + "additionalProperties": true + }, + "Actions": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "enum": [ + "merge", + "ready", + "draft", + "close", + "reopen", + "delete-branch", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + "revert", + "approve-workflows" + ] + }, + { + "type": "null" + } + ] + } + }, + "Agent": { + "type": "object", + "properties": { + "requiredVersion": { + "type": "string" + }, + "installedVersions": { + "type": "array", + "items": { + "type": "string" + } + }, + "runningVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "requiredVersion", + "installedVersions", + "runningVersion" + ], + "additionalProperties": true + }, + "AgentSessionImportProjectChangedErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AgentSessionImportProjectChangedError" + ] + }, + "projectId": { + "type": "string" + } + }, + "required": [ + "_tag", + "projectId" + ], + "additionalProperties": true + }, + "AgentSessionImportProjectNotFoundErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AgentSessionImportProjectNotFoundError" + ] + }, + "projectId": { + "type": "string" + } + }, + "required": [ + "_tag", + "projectId" + ], + "additionalProperties": true + }, + "AgentSessionScanErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AgentSessionScanError" + ] + }, + "operation": { + "type": "string", + "enum": [ + "read-settings", + "read-projects" + ] + }, + "cause": {} + }, + "required": [ + "_tag", + "operation", + "cause" + ], + "additionalProperties": true + }, + "AgentSessionsImportError": { + "anyOf": [ + { + "$ref": "#/$defs/AgentSessionImportProjectChangedErrorEncoded" + }, + { + "$ref": "#/$defs/AgentSessionImportProjectNotFoundErrorEncoded" + }, + { + "$ref": "#/$defs/AgentSessionScanErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "AgentSessionsImportPayload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "expectedWorkspaceRoot": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "projectId" + ], + "additionalProperties": true + }, + "AgentSessionsImportSuccess": { + "type": "object", + "properties": { + "importedCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "skippedCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "importedCount", + "skippedCount" + ], + "additionalProperties": true + }, + "AgentSessionsScanError": { + "anyOf": [ + { + "$ref": "#/$defs/AgentSessionScanErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "AgentSessionsScanPayload": { + "not": { + "type": "null" + } + }, + "AgentSessionsScanSuccess": { + "type": "object", + "properties": { + "candidates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "title": { + "type": "string" + }, + "projectId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sources": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "claudeAgent", + "codex", + "pi" + ] + } + }, + "threadCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "lastActiveAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "alreadyImported": { + "type": "boolean" + }, + "git": { + "anyOf": [ + { + "type": "object", + "properties": { + "remoteKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "repository": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "remoteKey", + "repository" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "path", + "title", + "sources", + "threadCount", + "lastActiveAt", + "alreadyImported" + ], + "additionalProperties": true + } + }, + "scannedAt": { + "type": "string" + }, + "truncated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "candidates", + "scannedAt" + ], + "additionalProperties": true + }, + "AllT3": { + "type": "object", + "properties": { + "processCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "currentCpuPercent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "cpuTimeMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "currentRssBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "peakRssBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "ioReadBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "ioWriteBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "ioReadBytesPerSecond": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "ioWriteBytesPerSecond": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "processStarts": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "processExits": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "processCount", + "currentCpuPercent", + "cpuTimeMs", + "currentRssBytes", + "peakRssBytes", + "ioReadBytes", + "ioWriteBytes", + "ioReadBytesPerSecond", + "ioWriteBytesPerSecond", + "processStarts", + "processExits" + ], + "additionalProperties": true + }, + "App": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "app-id" + ] + }, + "appId": { + "type": "string" + } + }, + "required": [ + "_tag", + "appId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "display-name" + ] + }, + "displayName": { + "type": "string" + } + }, + "required": [ + "_tag", + "displayName" + ], + "additionalProperties": true + } + ] + }, + "AssetAttachmentNotFoundErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AssetAttachmentNotFoundError" + ] + }, + "resource": { + "$ref": "#/$defs/Resource" + } + }, + "required": [ + "_tag", + "resource" + ], + "additionalProperties": true + }, + "AssetGitHubMediaUrlValidationErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AssetGitHubMediaUrlValidationError" + ] + } + }, + "required": [ + "_tag" + ], + "additionalProperties": true + }, + "AssetPreviewTypeValidationErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AssetPreviewTypeValidationError" + ] + }, + "resource": { + "$ref": "#/$defs/Resource" + } + }, + "required": [ + "_tag", + "resource" + ], + "additionalProperties": true + }, + "AssetProjectFaviconInspectionErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AssetProjectFaviconInspectionError" + ] + }, + "resource": { + "$ref": "#/$defs/Resource" + }, + "cause": {} + }, + "required": [ + "_tag", + "resource", + "cause" + ], + "additionalProperties": true + }, + "AssetProjectFaviconNotFoundErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AssetProjectFaviconNotFoundError" + ] + }, + "resource": { + "$ref": "#/$defs/Resource" + } + }, + "required": [ + "_tag", + "resource" + ], + "additionalProperties": true + }, + "AssetProjectFaviconResolutionErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AssetProjectFaviconResolutionError" + ] + }, + "resource": { + "$ref": "#/$defs/Resource" + }, + "cause": {} + }, + "required": [ + "_tag", + "resource", + "cause" + ], + "additionalProperties": true + }, + "AssetSigningKeyLoadErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AssetSigningKeyLoadError" + ] + }, + "resource": { + "$ref": "#/$defs/Resource" + }, + "cause": {} + }, + "required": [ + "_tag", + "resource", + "cause" + ], + "additionalProperties": true + }, + "AssetWorkspaceAssetInspectionErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AssetWorkspaceAssetInspectionError" + ] + }, + "resource": { + "$ref": "#/$defs/Resource" + }, + "cause": {} + }, + "required": [ + "_tag", + "resource", + "cause" + ], + "additionalProperties": true + }, + "AssetWorkspaceAssetNotFoundErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AssetWorkspaceAssetNotFoundError" + ] + }, + "resource": { + "$ref": "#/$defs/Resource" + }, + "checkedPath": { + "type": "string" + } + }, + "required": [ + "_tag", + "resource" + ], + "additionalProperties": true + }, + "AssetWorkspaceContextNotFoundErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AssetWorkspaceContextNotFoundError" + ] + }, + "resource": { + "$ref": "#/$defs/Resource" + } + }, + "required": [ + "_tag", + "resource" + ], + "additionalProperties": true + }, + "AssetWorkspaceContextResolutionErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AssetWorkspaceContextResolutionError" + ] + }, + "resource": { + "$ref": "#/$defs/Resource" + }, + "cause": {} + }, + "required": [ + "_tag", + "resource", + "cause" + ], + "additionalProperties": true + }, + "AssetWorkspacePathValidationErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AssetWorkspacePathValidationError" + ] + }, + "resource": { + "$ref": "#/$defs/Resource" + }, + "cause": {} + }, + "required": [ + "_tag", + "resource", + "cause" + ], + "additionalProperties": true + }, + "AssetWorkspaceResolutionErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AssetWorkspaceResolutionError" + ] + }, + "resource": { + "$ref": "#/$defs/Resource" + }, + "cause": {} + }, + "required": [ + "_tag", + "resource", + "cause" + ], + "additionalProperties": true + }, + "AssetWorkspaceRootNormalizationErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AssetWorkspaceRootNormalizationError" + ] + }, + "resource": { + "$ref": "#/$defs/Resource" + }, + "cause": {} + }, + "required": [ + "_tag", + "resource", + "cause" + ], + "additionalProperties": true + }, + "AssetsCreateUrlError": { + "anyOf": [ + { + "$ref": "#/$defs/AssetWorkspaceContextNotFoundErrorEncoded" + }, + { + "$ref": "#/$defs/AssetWorkspaceContextResolutionErrorEncoded" + }, + { + "$ref": "#/$defs/AssetWorkspaceRootNormalizationErrorEncoded" + }, + { + "$ref": "#/$defs/AssetWorkspacePathValidationErrorEncoded" + }, + { + "$ref": "#/$defs/AssetPreviewTypeValidationErrorEncoded" + }, + { + "$ref": "#/$defs/AssetWorkspaceAssetInspectionErrorEncoded" + }, + { + "$ref": "#/$defs/AssetWorkspaceAssetNotFoundErrorEncoded" + }, + { + "$ref": "#/$defs/AssetWorkspaceResolutionErrorEncoded" + }, + { + "$ref": "#/$defs/AssetAttachmentNotFoundErrorEncoded" + }, + { + "$ref": "#/$defs/AssetProjectFaviconResolutionErrorEncoded" + }, + { + "$ref": "#/$defs/AssetProjectFaviconInspectionErrorEncoded" + }, + { + "$ref": "#/$defs/AssetProjectFaviconNotFoundErrorEncoded" + }, + { + "$ref": "#/$defs/AssetGitHubMediaUrlValidationErrorEncoded" + }, + { + "$ref": "#/$defs/AssetSigningKeyLoadErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "AssetsCreateUrlPayload": { + "type": "object", + "properties": { + "resource": { + "$ref": "#/$defs/Resource" + } + }, + "required": [ + "resource" + ], + "additionalProperties": true + }, + "AssetsCreateUrlSuccess": { + "type": "object", + "properties": { + "relativeUrl": { + "type": "string" + }, + "expiresAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "sourcePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "imageDimensions": { + "anyOf": [ + { + "$ref": "#/$defs/ImageSize" + }, + { + "type": "null" + } + ] + }, + "fileRevision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "relativeUrl", + "expiresAt" + ], + "additionalProperties": true + }, + "AssetsPersistChatAttachmentsError": { + "anyOf": [ + { + "$ref": "#/$defs/PersistChatAttachmentsErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "AssetsPersistChatAttachmentsPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "attachments": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "image" + ] + }, + "name": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "sizeBytes": { + "type": "integer", + "minimum": 0, + "maximum": 10485760 + }, + "dataUrl": { + "type": "string" + }, + "source": { + "anyOf": [ + { + "$ref": "#/$defs/Source" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "name", + "mimeType", + "sizeBytes", + "dataUrl" + ], + "additionalProperties": true + } + ] + } + } + }, + "required": [ + "threadId", + "messageId", + "attachments" + ], + "additionalProperties": true + }, + "AssetsPersistChatAttachmentsSuccess": { + "type": "object", + "properties": { + "attachments": { + "$ref": "#/$defs/Attachments" + } + }, + "required": [ + "attachments" + ], + "additionalProperties": true + }, + "AttachmentUploadSigningKeyErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AttachmentUploadSigningKeyError" + ] + }, + "cause": {} + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": true + }, + "Attachments": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/AttachmentsEntry2" + }, + { + "$ref": "#/$defs/AttachmentsEntry3" + }, + { + "type": "object", + "properties": { + "type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "sizeBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "id", + "name", + "mimeType", + "sizeBytes" + ], + "additionalProperties": true + } + ] + } + }, + "AttachmentsByQuestionId": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/AttachmentsEntry2" + }, + { + "$ref": "#/$defs/AttachmentsEntry3" + } + ] + }, + "maxItems": 100 + } + }, + "AttachmentsCreateUploadUrlError": { + "anyOf": [ + { + "$ref": "#/$defs/AttachmentUploadSigningKeyErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "AttachmentsCreateUploadUrlPayload": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ] + }, + "name": { + "type": "string" + }, + "mimeType": { + "type": "string", + "enum": [ + "image/gif", + "image/jpeg", + "image/png", + "image/webp" + ] + }, + "sizeBytes": { + "type": "integer", + "minimum": 1, + "maximum": 10485760 + } + }, + "required": [ + "name", + "mimeType", + "sizeBytes" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "name": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "sizeBytes": { + "type": "integer", + "minimum": 1, + "maximum": 52428800 + } + }, + "required": [ + "type", + "name", + "mimeType", + "sizeBytes" + ], + "additionalProperties": true + } + ] + }, + "AttachmentsCreateUploadUrlSuccess": { + "type": "object", + "properties": { + "attachmentId": { + "type": "string" + }, + "relativeUrl": { + "type": "string" + }, + "expiresAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "attachmentId", + "relativeUrl", + "expiresAt" + ], + "additionalProperties": true + }, + "AttachmentsDeletePayload": { + "type": "object", + "properties": { + "attachmentId": { + "type": "string" + } + }, + "required": [ + "attachmentId" + ], + "additionalProperties": true + }, + "AttachmentsDeleteSuccess": { + "type": "null" + }, + "AttachmentsEntry2": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "image" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "sizeBytes": { + "type": "integer", + "minimum": 0, + "maximum": 10485760 + }, + "source": { + "anyOf": [ + { + "$ref": "#/$defs/Source" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "id", + "name", + "mimeType", + "sizeBytes" + ], + "additionalProperties": true + }, + "AttachmentsEntry3": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "file" + ] + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "sizeBytes": { + "type": "integer", + "minimum": 1, + "maximum": 52428800 + }, + "source": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "pasted-text" + ] + } + }, + "required": [ + "_tag" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "id", + "name", + "mimeType", + "sizeBytes" + ], + "additionalProperties": true + }, + "AuthAccessStreamErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "AuthAccessStreamError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": true + }, + "Author": { + "type": "object", + "properties": { + "isBot": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "login": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "avatarUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "login", + "name", + "avatarUrl" + ], + "additionalProperties": true + }, + "Authors": { + "type": "array", + "items": { + "$ref": "#/$defs/Author" + } + }, + "BasePoint": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "checkpointId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "turnItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "providerTurnRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "threadId" + ], + "additionalProperties": true + }, + "BranchChanges": { + "type": "object", + "properties": { + "baseRef": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "insertions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "deletions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "baseRef", + "insertions", + "deletions" + ], + "additionalProperties": true + }, + "Checks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "action-required", + "success", + "failure", + "skipped", + "neutral", + "cancelled" + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "status", + "description", + "url" + ], + "additionalProperties": true + } + }, + "ClientSessionsEntry": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "scopes": { + "$ref": "#/$defs/Scopes" + }, + "permissions": { + "$ref": "#/$defs/Permissions" + }, + "method": { + "type": "string", + "enum": [ + "browser-session-cookie", + "bearer-access-token", + "dpop-access-token" + ] + }, + "client": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "ipAddress": { + "type": "string" + }, + "userAgent": { + "type": "string" + }, + "deviceType": { + "type": "string", + "enum": [ + "desktop", + "mobile", + "tablet", + "bot", + "unknown" + ] + }, + "os": { + "type": "string" + }, + "browser": { + "type": "string" + } + }, + "required": [ + "deviceType" + ], + "additionalProperties": true + }, + "issuedAt": { + "type": "string" + }, + "expiresAt": { + "type": "string" + }, + "lastConnectedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "connected": { + "type": "boolean" + }, + "current": { + "type": "boolean" + } + }, + "required": [ + "sessionId", + "subject", + "scopes", + "method", + "client", + "issuedAt", + "expiresAt", + "lastConnectedAt", + "connected", + "current" + ], + "additionalProperties": true + }, + "CloudGetRelayClientStatusSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "available" + ] + }, + "executablePath": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "override", + "managed", + "path" + ] + }, + "version": { + "type": "string" + } + }, + "required": [ + "status", + "executablePath", + "source", + "version" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "missing" + ] + }, + "version": { + "type": "string" + } + }, + "required": [ + "status", + "version" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "unsupported" + ] + }, + "platform": { + "type": "string" + }, + "arch": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "status", + "platform", + "arch", + "version" + ], + "additionalProperties": true + } + ] + }, + "CloudInstallRelayClientError": { + "anyOf": [ + { + "$ref": "#/$defs/RelayClientInstallFailedErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "CloudInstallRelayClientSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "progress" + ] + }, + "stage": { + "type": "string", + "enum": [ + "checking", + "waiting_for_lock", + "downloading", + "verifying", + "installing", + "validating", + "activating" + ] + } + }, + "required": [ + "type", + "stage" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "complete" + ] + }, + "status": { + "$ref": "#/$defs/CloudGetRelayClientStatusSuccess" + } + }, + "required": [ + "type", + "status" + ], + "additionalProperties": true + } + ] + }, + "Code": { + "type": "object", + "properties": { + "additions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "deletions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "additions", + "deletions" + ], + "additionalProperties": true + }, + "Command": { + "anyOf": [ + { + "type": "string", + "enum": [ + "workspace.focusNext", + "workspace.focusPrevious", + "sidebar.toggle", + "navigation.back", + "navigation.forward", + "terminal.toggle", + "terminal.split", + "terminal.splitVertical", + "terminal.new", + "terminal.close", + "rightPanel.toggle", + "rightPanel.new", + "threadPanel.toggle", + "rightPanel.toggleMaximized", + "rightPanel.close", + "view.reopenClosed", + "pullRequest.copyNumber", + "diff.toggle", + "preview.toggle", + "preview.refresh", + "preview.focusUrl", + "preview.zoomIn", + "preview.zoomOut", + "preview.resetZoom", + "commandPalette.toggle", + "filePicker.toggle", + "projectSearch.toggle", + "usage.open", + "theme.select", + "appearance.cycle", + "themeEditor.toggle", + "composer.stash", + "composer.sendAlternate", + "composer.sendBackground", + "composer.sendAndNewThread", + "composer.host", + "composer.cycleHost", + "composer.effort", + "composer.increaseEffort", + "composer.decreaseEffort", + "composer.mode", + "composer.workspace", + "composer.previousWorktree", + "composer.branch", + "chat.new", + "chat.newLocal", + "chat.newWithoutProject", + "chat.find", + "editor.openFavorite", + "usage.cost", + "usage.tokens", + "usage.limits", + "usage.period.day", + "usage.period.week", + "usage.period.month", + "usage.period.quarter", + "modelPicker.toggle", + "modelPicker.previousProvider", + "modelPicker.nextProvider", + "modelPicker.jump.1", + "modelPicker.jump.2", + "modelPicker.jump.3", + "modelPicker.jump.4", + "modelPicker.jump.5", + "modelPicker.jump.6", + "modelPicker.jump.7", + "modelPicker.jump.8", + "modelPicker.jump.9", + "thread.stop", + "thread.steerQueuedMessage", + "thread.editQueuedMessage", + "thread.previous", + "thread.next", + "thread.copyReference", + "thread.settle", + "thread.pin", + "thread.undo", + "thread.jump.1", + "thread.jump.2", + "thread.jump.3", + "thread.jump.4", + "thread.jump.5", + "thread.jump.6", + "thread.jump.7", + "thread.jump.8", + "thread.jump.9" + ] + }, + { + "type": "string", + "pattern": "^script\\.[\\s\\S]*?\\.run$" + } + ] + }, + "Comments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "author": { + "anyOf": [ + { + "$ref": "#/$defs/Author" + }, + { + "type": "null" + } + ] + }, + "body": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "editedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reactions": { + "anyOf": [ + { + "$ref": "#/$defs/Reactions" + }, + { + "type": "null" + } + ] + }, + "canEdit": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "author", + "body", + "createdAt", + "url" + ], + "additionalProperties": true + } + }, + "Context": { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "guest": { + "anyOf": [ + { + "type": "object", + "properties": { + "guestMessageId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "text": { + "type": "string", + "maxLength": 4000 + }, + "shareId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "guestMessageId", + "name", + "text" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "records": { + "type": "array", + "maxItems": 200 + } + }, + "required": [ + "version", + "records" + ], + "additionalProperties": true + }, + "Counts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "projectId", + "count" + ], + "additionalProperties": true + } + }, + "Dedupe": { + "type": "object", + "properties": { + "replayKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "coalesceKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "coalesceWindowSeconds": { + "anyOf": [ + { + "type": "integer", + "minimum": 10, + "maximum": 3600 + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true, + "description": "Replay and coalesce key templates. Window in seconds, default 120." + }, + "DefaultProjectScripts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "command": { + "type": "string" + }, + "icon": { + "type": "string", + "enum": [ + "play", + "test", + "lint", + "configure", + "build", + "debug" + ] + }, + "runOnWorktreeCreate": { + "type": "boolean" + }, + "runOnSettle": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "async": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "previewUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "autoOpenPreview": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "command", + "icon", + "runOnWorktreeCreate" + ], + "additionalProperties": true + } + }, + "DeferredWork": { + "type": "object", + "properties": { + "cursor": { + "type": "string" + }, + "itemCount": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "lastOrdinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "cursor", + "itemCount", + "lastOrdinal" + ], + "additionalProperties": true + }, + "DelegatedCompletion": { + "type": "object", + "properties": { + "parentRunId": { + "type": "string" + }, + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "taskIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "parentRunId", + "generation", + "taskIds" + ], + "additionalProperties": true + }, + "Desktop": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "starting", + "healthy", + "degraded", + "unavailable", + "stopped" + ] + }, + "lastSampleAt": { + "$ref": "#/$defs/LastError" + }, + "lastError": { + "$ref": "#/$defs/LastError" + } + }, + "required": [ + "status", + "lastSampleAt", + "lastError" + ], + "additionalProperties": true + }, + "DeviceActionError": { + "anyOf": [ + { + "$ref": "#/$defs/DeviceHostUnavailableErrorEncoded" + }, + { + "$ref": "#/$defs/DevicePlatformUnavailableErrorEncoded" + }, + { + "$ref": "#/$defs/DeviceNotFoundErrorEncoded" + }, + { + "$ref": "#/$defs/DeviceBootErrorEncoded" + }, + { + "$ref": "#/$defs/DeviceOperationErrorEncoded" + }, + { + "$ref": "#/$defs/DeviceActionUnavailableErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "DeviceActionPayload": { + "anyOf": [ + { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "setAppearance" + ] + }, + "value": { + "type": "string", + "enum": [ + "light", + "dark" + ] + } + }, + "required": [ + "deviceId", + "type", + "value" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "setTextSize" + ] + }, + "value": { + "type": "string", + "enum": [ + "small", + "default", + "large", + "extra-large" + ] + } + }, + "required": [ + "deviceId", + "type", + "value" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "setToggle" + ] + }, + "setting": { + "type": "string", + "enum": [ + "reduceMotion", + "increaseContrast", + "reduceTransparency", + "showBorders", + "voiceOver", + "networkEnabled" + ] + }, + "value": { + "type": "boolean" + } + }, + "required": [ + "deviceId", + "type", + "setting", + "value" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "setLiquidGlass" + ] + }, + "value": { + "type": "string", + "enum": [ + "clear", + "tinted" + ] + } + }, + "required": [ + "deviceId", + "type", + "value" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "setColorFilter" + ] + }, + "value": { + "type": "string", + "enum": [ + "none", + "grayscale", + "red-green", + "green-red", + "blue-yellow" + ] + } + }, + "required": [ + "deviceId", + "type", + "value" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "setOrientation" + ] + }, + "value": { + "type": "string", + "enum": [ + "portrait", + "landscape_left", + "portrait_upside_down", + "landscape_right" + ] + } + }, + "required": [ + "deviceId", + "type", + "value" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "setLocation" + ] + }, + "latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "deviceId", + "type", + "latitude", + "longitude" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "clearLocation" + ] + } + }, + "required": [ + "deviceId", + "type" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "setPermission" + ] + }, + "appId": { + "type": "string" + }, + "permission": { + "type": "string", + "enum": [ + "camera", + "microphone", + "photos", + "contacts", + "calendar", + "reminders", + "location", + "notifications", + "motion", + "media-library", + "faceid" + ] + }, + "decision": { + "type": "string", + "enum": [ + "grant", + "revoke", + "reset" + ] + } + }, + "required": [ + "deviceId", + "type", + "appId", + "permission", + "decision" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "openUrl" + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "deviceId", + "type", + "url" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "launchApp" + ] + }, + "appId": { + "type": "string" + } + }, + "required": [ + "deviceId", + "type", + "appId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "terminateApp" + ] + }, + "appId": { + "type": "string" + } + }, + "required": [ + "deviceId", + "type", + "appId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "shake" + ] + } + }, + "required": [ + "deviceId", + "type" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "sendPush" + ] + }, + "appId": { + "type": "string" + }, + "payload": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object" + } + ] + } + }, + "required": [ + "deviceId", + "type", + "appId", + "payload" + ], + "additionalProperties": true + } + ] + }, + "DeviceActionSuccess": { + "type": "object", + "properties": { + "hostId": { + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "settings": { + "type": "object", + "properties": { + "appearance": { + "anyOf": [ + { + "type": "string", + "enum": [ + "light", + "dark" + ] + }, + { + "type": "null" + } + ] + }, + "textSize": { + "anyOf": [ + { + "type": "string", + "enum": [ + "small", + "default", + "large", + "extra-large" + ] + }, + { + "type": "null" + } + ] + }, + "reduceMotion": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "increaseContrast": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "reduceTransparency": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "showBorders": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "voiceOver": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "liquidGlass": { + "anyOf": [ + { + "type": "string", + "enum": [ + "clear", + "tinted" + ] + }, + { + "type": "null" + } + ] + }, + "colorFilter": { + "anyOf": [ + { + "type": "string", + "enum": [ + "none", + "grayscale", + "red-green", + "green-red", + "blue-yellow" + ] + }, + { + "type": "null" + } + ] + }, + "networkEnabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "location": { + "anyOf": [ + { + "type": "object", + "properties": { + "latitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "longitude": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "latitude", + "longitude" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + "foregroundApp": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "readAt": { + "type": "string" + } + }, + "required": [ + "hostId", + "deviceId", + "settings", + "foregroundApp", + "readAt" + ], + "additionalProperties": true + }, + "DeviceActionUnavailableErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DeviceActionUnavailableError" + ] + }, + "operation": { + "type": "string" + }, + "platform": { + "type": "string", + "enum": [ + "ios", + "android" + ] + }, + "reason": { + "type": "string", + "enum": [ + "unsupported", + "helper_missing" + ] + } + }, + "required": [ + "_tag", + "operation", + "platform", + "reason" + ], + "additionalProperties": true + }, + "DeviceBootErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DeviceBootError" + ] + }, + "hostId": { + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": [ + "disk_space", + "timeout", + "launch_failed" + ] + }, + "diagnostics": { + "anyOf": [ + { + "type": "string", + "maxLength": 2000 + }, + { + "type": "null" + } + ] + }, + "cause": {} + }, + "required": [ + "_tag", + "hostId", + "deviceId", + "reason", + "cause" + ], + "additionalProperties": true + }, + "DeviceClosePayload": { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "threadId": { + "type": "string" + }, + "deviceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "shutdown": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "threadId" + ], + "additionalProperties": true + }, + "DeviceConfigurePayload": { + "type": "object", + "properties": { + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "agentAccessEnabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "onboardingCompleted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + "DeviceConfigureSuccess": { + "type": "object", + "properties": { + "supportsHostRetry": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "supportsToolUpdate": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "supportsToolInspection": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "supportsEasHosts": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "supportsSimlockHosts": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "hostStatus": { + "type": "string", + "enum": [ + "disabled", + "idle", + "installing", + "starting", + "ready", + "failed" + ] + }, + "hostStatusDetail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hostStatuses": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "disabled", + "idle", + "installing", + "starting", + "ready", + "failed" + ] + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "androidDiscoveryIncomplete": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": true + } + }, + "devices": { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "platform": { + "type": "string", + "enum": [ + "ios", + "android" + ] + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "booted": { + "type": "boolean" + }, + "physical": { + "type": "boolean" + } + }, + "required": [ + "hostId", + "id", + "platform", + "name", + "version", + "booted", + "physical" + ], + "additionalProperties": true + } + }, + "sessions": { + "type": "array", + "items": { + "$ref": "#/$defs/SessionsEntry" + } + }, + "bootingDevices": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "hostId": { + "type": "string" + }, + "id": { + "type": "string" + }, + "platform": { + "type": "string", + "enum": [ + "ios", + "android" + ] + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "booted": { + "type": "boolean" + }, + "physical": { + "type": "boolean" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "hostId", + "id", + "platform", + "name", + "version", + "booted", + "physical", + "threadId" + ], + "additionalProperties": true + } + }, + { + "type": "null" + } + ] + }, + "onboardingCompleted": { + "type": "boolean" + }, + "agentAccessEnabled": { + "type": "boolean" + }, + "hubBasePath": { + "type": "string" + }, + "revision": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "hosts": { + "type": "array", + "items": { + "$ref": "#/$defs/HostsEntry" + } + }, + "easHosts": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/EasHostsEntry" + } + }, + { + "type": "null" + } + ] + }, + "simlockHosts": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/SimlockHostsEntry" + } + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "hostStatus", + "hostStatuses", + "devices", + "sessions", + "onboardingCompleted", + "agentAccessEnabled", + "hubBasePath", + "revision", + "hosts" + ], + "additionalProperties": true + }, + "DeviceDetailPayload": { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string" + } + }, + "required": [ + "deviceId" + ], + "additionalProperties": true + }, + "DeviceEasAccountError": { + "anyOf": [ + { + "$ref": "#/$defs/DeviceEasAccountErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "DeviceEasAccountErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DeviceEasAccountError" + ] + }, + "reason": { + "type": "string" + } + }, + "required": [ + "_tag", + "reason" + ], + "additionalProperties": true + }, + "DeviceEasAccountPayload": { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "accessToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "reference": { + "type": "string" + } + }, + "required": [ + "provider", + "reference" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + "DeviceEasAccountSuccess": { + "type": "object", + "properties": { + "signedInAs": { + "type": "string" + }, + "credentialSource": { + "type": "string", + "enum": [ + "settings", + "env", + "eas-login" + ] + }, + "apps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "fullName": { + "type": "string" + } + }, + "required": [ + "id", + "fullName" + ], + "additionalProperties": true + } + } + }, + "required": [ + "signedInAs", + "credentialSource", + "apps" + ], + "additionalProperties": true + }, + "DeviceHostUnavailableErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DeviceHostUnavailableError" + ] + }, + "hostId": { + "type": "string" + }, + "hostLabel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reason": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "hostId", + "reason" + ], + "additionalProperties": true + }, + "DeviceHosts": { + "type": "array", + "items": { + "$ref": "#/$defs/DeviceTestHostPayload" + } + }, + "DeviceListPayload": { + "type": "object", + "properties": { + "updateTool": { + "anyOf": [ + { + "type": "string", + "enum": [ + "hub", + "agent" + ] + }, + { + "type": "null" + } + ] + }, + "inspectOnly": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "retryHostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + "DeviceNotFoundErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DeviceNotFoundError" + ] + }, + "hostId": { + "type": "string" + }, + "deviceId": { + "type": "string" + } + }, + "required": [ + "_tag", + "hostId", + "deviceId" + ], + "additionalProperties": true + }, + "DeviceOpenPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string" + }, + "platform": { + "type": "string", + "enum": [ + "ios", + "android" + ] + }, + "boot": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "threadId", + "deviceId", + "platform" + ], + "additionalProperties": true + }, + "DeviceOperationErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DeviceOperationError" + ] + }, + "operation": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": [ + "command_failed", + "request_failed", + "invalid_payload", + "settings_failed", + "hub_rejected" + ] + }, + "exitCode": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "cause": {} + }, + "required": [ + "_tag", + "operation", + "reason", + "cause" + ], + "additionalProperties": true + }, + "DevicePlatformUnavailableErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DevicePlatformUnavailableError" + ] + }, + "hostId": { + "type": "string" + }, + "platform": { + "type": "string", + "enum": [ + "ios", + "android" + ] + }, + "reason": { + "type": "string" + } + }, + "required": [ + "_tag", + "hostId", + "platform", + "reason" + ], + "additionalProperties": true + }, + "DeviceShutdownPayload": { + "type": "object", + "properties": { + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deviceId": { + "type": "string" + }, + "platform": { + "type": "string", + "enum": [ + "ios", + "android" + ] + } + }, + "required": [ + "deviceId", + "platform" + ], + "additionalProperties": true + }, + "DeviceSimlockIdentityError": { + "anyOf": [ + { + "$ref": "#/$defs/DeviceSimlockIdentityErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "DeviceSimlockIdentityErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "DeviceSimlockIdentityError" + ] + }, + "reason": { + "type": "string" + } + }, + "required": [ + "_tag", + "reason" + ], + "additionalProperties": true + }, + "DeviceSimlockIdentityPayload": { + "type": "object", + "properties": { + "gatewayUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hostId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "agentToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + "DeviceSimlockIdentitySuccess": { + "type": "object", + "properties": { + "gatewayUrl": { + "type": "string" + }, + "signedInAs": { + "type": "string" + }, + "tokenSource": { + "type": "string", + "enum": [ + "entered", + "settings", + "env" + ] + } + }, + "required": [ + "gatewayUrl", + "signedInAs", + "tokenSource" + ], + "additionalProperties": true + }, + "DeviceTestHostPayload": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "ssh" + ] + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "target": { + "type": "string" + }, + "identityFile": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "port": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "label", + "target" + ], + "additionalProperties": true + }, + "DeviceTestHostSuccess": { + "anyOf": [ + { + "$ref": "#/$defs/HostsEntry" + }, + { + "$ref": "#/$defs/EasHostsEntry" + }, + { + "$ref": "#/$defs/SimlockHostsEntry" + } + ] + }, + "EasDeviceHosts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "eas" + ] + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "appId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "accessToken": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "reference": { + "type": "string" + } + }, + "required": [ + "provider", + "reference" + ], + "additionalProperties": true + } + ] + }, + "maxSessionMinutes": { + "type": "integer", + "minimum": 1, + "maximum": 115 + }, + "idleMinutes": { + "type": "integer", + "minimum": 1, + "maximum": 60 + }, + "maxConcurrentSessions": { + "type": "integer", + "minimum": 1, + "maximum": 20 + } + }, + "required": [ + "kind", + "id", + "label" + ], + "additionalProperties": true + } + }, + "EasHostsEntry": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "platforms": { + "$ref": "#/$defs/Platforms" + }, + "tools": { + "anyOf": [ + { + "$ref": "#/$defs/Tools" + }, + { + "type": "null" + } + ] + }, + "toolInspectionError": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hubInstalled": { + "type": "boolean" + }, + "agentDeviceInstalled": { + "type": "boolean" + }, + "kind": { + "type": "string", + "enum": [ + "eas" + ] + } + }, + "required": [ + "id", + "label", + "platforms", + "hubInstalled", + "agentDeviceInstalled", + "kind" + ], + "additionalProperties": true + }, + "Editor": { + "type": "string", + "enum": [ + "cursor", + "trae", + "kiro", + "vscode", + "vscode-insiders", + "vscodium", + "zed", + "antigravity", + "idea", + "aqua", + "clion", + "datagrip", + "dataspell", + "goland", + "phpstorm", + "pycharm", + "rider", + "rubymine", + "rustrover", + "webstorm", + "file-manager" + ] + }, + "EndedWatch": { + "type": "object", + "properties": { + "startedAt": { + "type": "string" + }, + "headSha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "failedChecks": { + "type": "array", + "items": { + "type": "string" + } + }, + "passed": { + "type": "boolean" + }, + "passedChecks": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "owedChecks": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "remarksThrough": { + "type": "string" + }, + "remarkIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "conflicting": { + "type": "boolean" + }, + "wakes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "checksAwaitedSince": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "instructions": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "startedAt", + "headSha", + "failedChecks", + "passed", + "remarksThrough", + "remarkIds", + "conflicting", + "wakes" + ], + "additionalProperties": true + }, + "Environment": { + "type": "object", + "properties": { + "environmentId": { + "type": "string" + }, + "label": { + "type": "string" + }, + "platform": { + "type": "object", + "properties": { + "os": { + "type": "string", + "enum": [ + "darwin", + "linux", + "windows", + "unknown" + ] + }, + "arch": { + "type": "string", + "enum": [ + "arm64", + "x64", + "other" + ] + }, + "machine": {} + }, + "required": [ + "os", + "arch" + ], + "additionalProperties": true + }, + "serverVersion": { + "type": "string" + }, + "orchestrationProtocolVersion": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "databaseMigration": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "capabilities": { + "type": "object", + "properties": { + "repositoryIdentity": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "connectionProbe": { + "type": "boolean" + }, + "attachmentUploads": { + "type": "boolean" + }, + "questionAttachments": { + "type": "boolean" + }, + "fileAttachments": { + "type": "object", + "properties": { + "maxUploadBytes": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "maxUploadBytes" + ], + "additionalProperties": true + }, + "pullRequests": { + "type": "boolean" + }, + "pullRequestChecks": { + "type": "boolean" + }, + "sideChats": { + "type": "boolean" + }, + "inlineMessageContext": { + "type": "boolean" + }, + "requiredWorktreeBootstrap": { + "type": "boolean" + }, + "threadSettlement": { + "type": "boolean" + }, + "threadAutoSettlement": { + "type": "boolean" + }, + "storageCleanup": { + "type": "boolean" + }, + "storageCleanupRun": { + "type": "boolean" + }, + "databaseStorage": { + "type": "boolean" + }, + "otlpEndpointCheck": { + "type": "boolean" + }, + "projectWorktreeCleanup": { + "type": "boolean" + }, + "settledWorktreeCleanup": { + "type": "boolean" + }, + "worktreesDirectory": { + "type": "boolean" + }, + "threadAutoSettlementScope": { + "type": "boolean" + }, + "threadRestartContinuation": { + "type": "boolean" + }, + "projectSettingsOverrides": { + "type": "boolean" + }, + "projectProviderInstanceOverrides": { + "type": "boolean" + }, + "threadSnooze": { + "type": "boolean" + }, + "threadForceStop": { + "type": "boolean" + }, + "environmentThemes": { + "type": "boolean" + }, + "usageLimitSources": { + "type": "boolean" + }, + "providerWorkspaceSnapshotsOnDemand": { + "type": "boolean" + }, + "usagePriceOverrides": { + "type": "boolean" + }, + "usageModelAliases": { + "type": "boolean" + }, + "threadPinning": { + "type": "boolean" + }, + "threadPinReorder": { + "type": "boolean" + }, + "threadActiveReorder": { + "type": "boolean" + }, + "threadAutoSettleOptOut": { + "type": "boolean" + }, + "threadProjectFromScratch": { + "type": "boolean" + }, + "threadTitleRegeneration": { + "type": "boolean" + }, + "threadVisitedTracking": { + "type": "boolean" + }, + "threadPullRequestLinking": { + "type": "boolean" + }, + "serverResolvedCommandContext": { + "type": "boolean" + }, + "threadStop": { + "type": "boolean" + }, + "localServerStop": { + "type": "boolean" + }, + "threadPullRequests": { + "type": "boolean" + }, + "threadPullRequestWatch": { + "type": "boolean" + }, + "pullRequestStackActions": { + "type": "boolean" + }, + "serverSelfUpdate": { + "type": "string", + "enum": [ + "boot-service", + "respawn", + "desktop-managed" + ] + }, + "npmReleaseLookup": { + "type": "boolean" + }, + "serverInstallation": {}, + "serverSelfUpdateProgress": { + "type": "boolean" + }, + "serverUpdateThreadContinuation": { + "type": "boolean" + }, + "agentActivityPublishing": { + "type": "boolean" + }, + "projectCloneTracking": { + "type": "boolean" + }, + "projectOrganization": { + "type": "boolean" + }, + "ignoredFileListing": { + "type": "boolean" + }, + "environmentIcon": { + "type": "boolean" + }, + "desktopAppUpdate": { + "type": "boolean" + }, + "desktopAppChannelSwitch": { + "type": "boolean" + }, + "commandOutputStreaming": { + "type": "boolean" + }, + "serverBrowser": { + "type": "boolean" + }, + "peerLinks": { + "type": "boolean" + }, + "mcpModeLimitHeader": { + "type": "boolean" + }, + "linkFence": { + "type": "boolean" + }, + "triage": { + "type": "boolean" + }, + "oneTimeSchedules": { + "type": "boolean" + } + }, + "additionalProperties": true + } + }, + "required": [ + "environmentId", + "label", + "platform", + "serverVersion", + "capabilities" + ], + "additionalProperties": true + }, + "EnvironmentAuthorizationErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "EnvironmentAuthorizationError" + ] + }, + "message": { + "type": "string" + }, + "requiredScope": { + "$ref": "#/$defs/PermissionsEntry" + }, + "requiredPermission": { + "type": "string" + } + }, + "required": [ + "_tag", + "message", + "requiredScope" + ], + "additionalProperties": true + }, + "EnvironmentThemes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "version": { + "anyOf": [ + { + "type": "number", + "enum": [ + 1 + ] + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "appearance": { + "type": "string", + "enum": [ + "light", + "dark" + ] + }, + "canvas": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "accent": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "colors": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "variants": { + "anyOf": [ + { + "type": "object", + "properties": { + "light": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "dark": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "name", + "appearance" + ], + "additionalProperties": true + } + }, + "ExternalLauncherBrowserSpawnErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ExternalLauncherBrowserSpawnError" + ] + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cause": {}, + "target": { + "type": "string" + } + }, + "required": [ + "_tag", + "command", + "args", + "cause", + "target" + ], + "additionalProperties": true + }, + "ExternalLauncherCommandNotFoundErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ExternalLauncherCommandNotFoundError" + ] + }, + "editor": { + "$ref": "#/$defs/Editor" + }, + "command": { + "type": "string" + } + }, + "required": [ + "_tag", + "editor", + "command" + ], + "additionalProperties": true + }, + "ExternalLauncherEditorSpawnErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ExternalLauncherEditorSpawnError" + ] + }, + "command": { + "type": "string" + }, + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "cause": {}, + "editor": { + "$ref": "#/$defs/Editor" + }, + "target": { + "type": "string" + } + }, + "required": [ + "_tag", + "command", + "args", + "cause", + "editor", + "target" + ], + "additionalProperties": true + }, + "ExternalLauncherUnknownEditorErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ExternalLauncherUnknownEditorError" + ] + }, + "editor": { + "type": "string" + } + }, + "required": [ + "_tag", + "editor" + ], + "additionalProperties": true + }, + "ExternalLauncherUnsupportedEditorErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ExternalLauncherUnsupportedEditorError" + ] + }, + "editor": { + "$ref": "#/$defs/Editor" + } + }, + "required": [ + "_tag", + "editor" + ], + "additionalProperties": true + }, + "ExternalLauncherUnsupportedTargetErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ExternalLauncherUnsupportedTargetError" + ] + }, + "editor": { + "$ref": "#/$defs/Editor" + } + }, + "required": [ + "_tag", + "editor" + ], + "additionalProperties": true + }, + "Failure": { + "type": "object", + "properties": { + "class": { + "type": "string", + "enum": [ + "usage_limit", + "provider_error", + "transport_error", + "permission_error", + "validation_error", + "unknown" + ] + }, + "message": { + "type": "string" + }, + "code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "retryable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "resetAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "class", + "message", + "code", + "retryable" + ], + "additionalProperties": true + }, + "Failure2": { + "type": "string", + "enum": [ + "workspace_root_not_found", + "workspace_root_create_failed", + "workspace_root_stat_failed", + "workspace_root_not_directory", + "search_index_create_failed", + "search_index_scan_timed_out", + "search_index_search_failed", + "directory_list_failed" + ] + }, + "Files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "additions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "deletions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "path", + "kind", + "additions", + "deletions" + ], + "additionalProperties": true + } + }, + "FilesTotal": { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "additions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "deletions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "count", + "additions", + "deletions" + ], + "additionalProperties": true + }, + "FilesystemBrowseError": { + "anyOf": [ + { + "$ref": "#/$defs/FilesystemBrowseErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "FilesystemBrowseErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "FilesystemBrowseError" + ] + }, + "partialPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "failure": { + "anyOf": [ + { + "type": "string", + "enum": [ + "windows_path_unsupported", + "current_project_required", + "read_directory_failed" + ] + }, + { + "type": "null" + } + ] + }, + "parentPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "platform": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": true + }, + "FilesystemBrowsePayload": { + "type": "object", + "properties": { + "partialPath": { + "type": "string" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "partialPath" + ], + "additionalProperties": true + }, + "FilesystemBrowseSuccess": { + "type": "object", + "properties": { + "parentPath": { + "type": "string" + }, + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "fullPath": { + "type": "string" + } + }, + "required": [ + "name", + "fullPath" + ], + "additionalProperties": true + } + } + }, + "required": [ + "parentPath", + "entries" + ], + "additionalProperties": true + }, + "FilesystemGetMetadataPayload": { + "type": "object", + "properties": { + "paths": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "maxItems": 64 + } + }, + "required": [ + "paths" + ], + "additionalProperties": true + }, + "FilesystemGetMetadataSuccess": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "file", + "directory", + "other" + ] + }, + "byteLength": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "mimeType": { + "type": "string" + } + }, + "required": [ + "kind" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "entries" + ], + "additionalProperties": true + }, + "Filters": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "op": { + "type": "string", + "enum": [ + "equals", + "notEquals", + "startsWith" + ] + }, + "value": { + "type": "string" + } + }, + "required": [ + "path", + "op", + "value" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "op": { + "type": "string", + "enum": [ + "in" + ] + }, + "value": { + "type": "array", + "items": { + "type": "string", + "description": "Non-empty, without commas or surrounding whitespace." + }, + "minItems": 1 + } + }, + "required": [ + "path", + "op", + "value" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "op": { + "type": "string", + "enum": [ + "exists" + ] + } + }, + "required": [ + "path", + "op" + ], + "additionalProperties": true + } + ], + "description": "Path: `headers.x-event` or `body.action`. String compare. Objects pass only 'exists'. A `[]` segment matches any array item. `notEquals` passes only if no item equals." + }, + "maxItems": 16, + "description": "All must match. Max 16. Omitted: keep stored. [] clears." + }, + "ForkedFrom": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "provider_thread" + ] + }, + "providerThreadId": { + "type": "string" + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "providerThreadId" + ], + "additionalProperties": true + }, + "GitCommandErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "GitCommandError" + ] + }, + "operation": { + "type": "string" + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "argumentCount": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "exitCode": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "stdoutLength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "stderrLength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "outputLength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "reason": {}, + "detail": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "operation", + "command", + "cwd", + "detail" + ], + "additionalProperties": true + }, + "GitManagerErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "GitManagerError" + ] + }, + "operation": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "operation", + "cwd", + "detail" + ], + "additionalProperties": true + }, + "GitPreparePullRequestThreadError": { + "anyOf": [ + { + "$ref": "#/$defs/GitManagerErrorEncoded" + }, + { + "$ref": "#/$defs/GitPullRequestMaterializationErrorEncoded" + }, + { + "$ref": "#/$defs/GitCommandErrorEncoded" + }, + { + "$ref": "#/$defs/SourceControlProviderErrorEncoded" + }, + { + "$ref": "#/$defs/TextGenerationErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "GitPreparePullRequestThreadPayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "reference": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "local", + "worktree" + ] + }, + "threadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "cwd", + "reference", + "mode" + ], + "additionalProperties": true + }, + "GitPreparePullRequestThreadSuccess": { + "type": "object", + "properties": { + "pullRequest": { + "$ref": "#/$defs/PullRequest" + }, + "branch": { + "type": "string" + }, + "worktreePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "isOnPullRequestHead": { + "type": "boolean" + } + }, + "required": [ + "pullRequest", + "branch", + "worktreePath" + ], + "additionalProperties": true + }, + "GitPullRequestMaterializationErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "GitPullRequestMaterializationError" + ] + }, + "cwd": { + "type": "string" + }, + "pullRequestNumber": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "headRepository": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "headBranch": { + "type": "string" + }, + "localBranch": { + "type": "string" + }, + "cause": {} + }, + "required": [ + "_tag", + "cwd", + "pullRequestNumber", + "headRepository", + "headBranch", + "localBranch", + "cause" + ], + "additionalProperties": true + }, + "GitResolvePullRequestPayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "reference": { + "type": "string" + } + }, + "required": [ + "cwd", + "reference" + ], + "additionalProperties": true + }, + "GitResolvePullRequestSuccess": { + "type": "object", + "properties": { + "pullRequest": { + "$ref": "#/$defs/PullRequest" + } + }, + "required": [ + "pullRequest" + ], + "additionalProperties": true + }, + "GitRunStackedActionPayload": { + "type": "object", + "properties": { + "actionId": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "commit", + "push", + "create_pr", + "commit_push", + "commit_push_pr" + ] + }, + "commitMessage": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "featureBranch": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "filePaths": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "minItems": 1 + }, + { + "type": "null" + } + ] + }, + "threadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "projectId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "actionId", + "cwd", + "action" + ], + "additionalProperties": true + }, + "GitRunStackedActionSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "actionId": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "commit", + "push", + "create_pr", + "commit_push", + "commit_push_pr" + ] + }, + "kind": { + "type": "string", + "enum": [ + "action_started" + ] + }, + "phases": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "branch", + "commit", + "push", + "pr" + ] + } + } + }, + "required": [ + "actionId", + "cwd", + "action", + "kind", + "phases" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "actionId": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "commit", + "push", + "create_pr", + "commit_push", + "commit_push_pr" + ] + }, + "kind": { + "type": "string", + "enum": [ + "phase_started" + ] + }, + "phase": { + "type": "string", + "enum": [ + "branch", + "commit", + "push", + "pr" + ] + }, + "label": { + "type": "string" + } + }, + "required": [ + "actionId", + "cwd", + "action", + "kind", + "phase", + "label" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "actionId": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "commit", + "push", + "create_pr", + "commit_push", + "commit_push_pr" + ] + }, + "kind": { + "type": "string", + "enum": [ + "hook_started" + ] + }, + "hookName": { + "type": "string" + } + }, + "required": [ + "actionId", + "cwd", + "action", + "kind", + "hookName" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "actionId": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "commit", + "push", + "create_pr", + "commit_push", + "commit_push_pr" + ] + }, + "kind": { + "type": "string", + "enum": [ + "hook_output" + ] + }, + "hookName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "stream": { + "type": "string", + "enum": [ + "stdout", + "stderr" + ] + }, + "text": { + "type": "string" + } + }, + "required": [ + "actionId", + "cwd", + "action", + "kind", + "hookName", + "stream", + "text" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "actionId": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "commit", + "push", + "create_pr", + "commit_push", + "commit_push_pr" + ] + }, + "kind": { + "type": "string", + "enum": [ + "hook_finished" + ] + }, + "hookName": { + "type": "string" + }, + "exitCode": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "durationMs": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "actionId", + "cwd", + "action", + "kind", + "hookName", + "exitCode", + "durationMs" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "actionId": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "commit", + "push", + "create_pr", + "commit_push", + "commit_push_pr" + ] + }, + "kind": { + "type": "string", + "enum": [ + "action_finished" + ] + }, + "result": { + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": [ + "commit", + "push", + "create_pr", + "commit_push", + "commit_push_pr" + ] + }, + "branch": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "created", + "skipped_not_requested" + ] + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": true + }, + "commit": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "created", + "skipped_no_changes", + "skipped_not_requested" + ] + }, + "commitSha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "subject": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": true + }, + "push": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "pushed", + "skipped_not_requested", + "skipped_up_to_date" + ] + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "upstreamBranch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "setUpstream": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": true + }, + "pr": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "created", + "opened_existing", + "skipped_not_requested" + ] + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "number": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "baseBranch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "headBranch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": true + }, + "toast": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cta": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "none" + ] + } + }, + "required": [ + "kind" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "open_pr" + ] + }, + "label": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "kind", + "label", + "url" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "run_action" + ] + }, + "label": { + "type": "string" + }, + "action": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "commit", + "push", + "create_pr", + "commit_push", + "commit_push_pr" + ] + } + }, + "required": [ + "kind" + ], + "additionalProperties": true + } + }, + "required": [ + "kind", + "label", + "action" + ], + "additionalProperties": true + } + ] + } + }, + "required": [ + "title", + "cta" + ], + "additionalProperties": true + } + }, + "required": [ + "action", + "branch", + "commit", + "push", + "pr", + "toast" + ], + "additionalProperties": true + } + }, + "required": [ + "actionId", + "cwd", + "action", + "kind", + "result" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "actionId": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "action": { + "type": "string", + "enum": [ + "commit", + "push", + "create_pr", + "commit_push", + "commit_push_pr" + ] + }, + "kind": { + "type": "string", + "enum": [ + "action_failed" + ] + }, + "phase": { + "anyOf": [ + { + "type": "string", + "enum": [ + "branch", + "commit", + "push", + "pr" + ] + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "actionId", + "cwd", + "action", + "kind", + "phase", + "message" + ], + "additionalProperties": true + } + ] + }, + "Goal": { + "type": "object", + "properties": { + "objective": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "active", + "paused", + "blocked", + "usage_limited", + "budget_limited", + "complete" + ] + }, + "tokensUsed": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "tokenBudget": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "timeUsedSeconds": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "checks": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "lastCheck": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "objective", + "status" + ], + "additionalProperties": true + }, + "GuestShare": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "host": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "expiresAt": { + "type": "string" + }, + "guestName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "sent", + "dismissed" + ] + }, + "submittedAt": { + "type": "string" + }, + "attachments": { + "anyOf": [ + { + "$ref": "#/$defs/Attachments" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "text", + "status", + "submittedAt" + ], + "additionalProperties": true + } + } + }, + "required": [ + "id", + "host", + "createdAt", + "expiresAt", + "guestName", + "messages" + ], + "additionalProperties": true + }, + "GuestShareCreateError": { + "anyOf": [ + { + "$ref": "#/$defs/GuestShareCreateErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "GuestShareCreateErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "GuestShareCreateError" + ] + }, + "reason": { + "type": "string", + "enum": [ + "disabled", + "connect_unavailable", + "thread_unavailable", + "read_failed", + "changed" + ] + } + }, + "required": [ + "_tag", + "reason" + ], + "additionalProperties": true + }, + "GuestShareCreatePayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "additionalProperties": true + }, + "GuestShareCreateSuccess": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "share": { + "$ref": "#/$defs/GuestShare" + } + }, + "required": [ + "url", + "share" + ], + "additionalProperties": true + }, + "Health": { + "type": "object", + "properties": { + "native": { + "$ref": "#/$defs/Desktop" + }, + "desktop": { + "$ref": "#/$defs/Desktop" + }, + "sidecarVersion": { + "$ref": "#/$defs/LastError" + }, + "sidecarPid": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Some" + ] + }, + "value": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "_tag", + "value" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "None" + ] + } + }, + "required": [ + "_tag" + ], + "additionalProperties": true + } + ] + }, + "restartCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "collectionDurationMicros": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "scannedProcessCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "retainedProcessCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "inaccessibleProcessCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "native", + "desktop", + "sidecarVersion", + "sidecarPid", + "restartCount", + "collectionDurationMicros", + "scannedProcessCount", + "retainedProcessCount", + "inaccessibleProcessCount" + ], + "additionalProperties": true + }, + "HostsEntry": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "platforms": { + "$ref": "#/$defs/Platforms" + }, + "tools": { + "anyOf": [ + { + "$ref": "#/$defs/Tools" + }, + { + "type": "null" + } + ] + }, + "toolInspectionError": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hubInstalled": { + "type": "boolean" + }, + "agentDeviceInstalled": { + "type": "boolean" + }, + "kind": { + "type": "string", + "enum": [ + "local", + "ssh" + ] + } + }, + "required": [ + "id", + "label", + "platforms", + "hubInstalled", + "agentDeviceInstalled", + "kind" + ], + "additionalProperties": true + }, + "Icon": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "website" + ] + }, + "pageUrl": { + "type": "string" + }, + "faviconUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "faviconUrlDark": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "pageUrl" + ], + "additionalProperties": true + }, + "Icon2": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "native-app" + ] + }, + "app": { + "$ref": "#/$defs/App" + } + }, + "required": [ + "_tag", + "app" + ], + "additionalProperties": true + }, + "Icon3": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "themed-logo" + ] + }, + "logoUrl": { + "type": "string" + }, + "logoUrlDark": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "logoUrl" + ], + "additionalProperties": true + }, + "Identity2": { + "type": "object", + "properties": { + "pid": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "startTimeMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "pid", + "startTimeMs" + ], + "additionalProperties": true + }, + "ImageSize": { + "type": "object", + "properties": { + "width": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "height": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "width", + "height" + ], + "additionalProperties": true + }, + "Issues": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "keybindings.malformed-config" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "kind", + "message" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "keybindings.invalid-entry" + ] + }, + "message": { + "type": "string" + }, + "index": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "kind", + "message", + "index" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "Item": { + "anyOf": [ + { + "$ref": "#/$defs/Item2" + }, + { + "$ref": "#/$defs/Item3" + }, + { + "$ref": "#/$defs/Item4" + }, + { + "$ref": "#/$defs/Item5" + }, + { + "$ref": "#/$defs/Item6" + }, + { + "$ref": "#/$defs/Item7" + }, + { + "$ref": "#/$defs/Item8" + }, + { + "$ref": "#/$defs/Item9" + }, + { + "$ref": "#/$defs/Item10" + }, + { + "$ref": "#/$defs/Item11" + }, + { + "$ref": "#/$defs/Item12" + }, + { + "$ref": "#/$defs/Item13" + }, + { + "$ref": "#/$defs/Item14" + }, + { + "$ref": "#/$defs/Item15" + }, + { + "$ref": "#/$defs/Item16" + }, + { + "$ref": "#/$defs/Item17" + }, + { + "$ref": "#/$defs/Item18" + }, + { + "$ref": "#/$defs/Item19" + }, + { + "$ref": "#/$defs/Item20" + }, + { + "$ref": "#/$defs/Item21" + }, + { + "$ref": "#/$defs/Item22" + }, + { + "$ref": "#/$defs/Item23" + }, + { + "$ref": "#/$defs/Item24" + }, + { + "$ref": "#/$defs/Item25" + }, + { + "$ref": "#/$defs/Item26" + }, + { + "type": "object", + "properties": { + "type": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": true + } + ] + }, + "Item10": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "command_execution" + ] + }, + "input": { + "type": "string" + }, + "output": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "outputOmitted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "outputIndicatesFailure": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "exitCode": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "monitor": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "input" + ], + "additionalProperties": true + }, + "Item11": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "file_search" + ] + }, + "pattern": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "results": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "fileName": { + "type": "string" + }, + "line": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "column": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "preview": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "fileName" + ], + "additionalProperties": true + } + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type" + ], + "additionalProperties": true + }, + "Item12": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "web_search" + ] + }, + "patterns": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "results": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "snippet": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + } + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type" + ], + "additionalProperties": true + }, + "Item13": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "approval_request" + ] + }, + "requestId": { + "type": "string" + }, + "requestKind": { + "type": "string", + "enum": [ + "command", + "file-read", + "file-change", + "mcp-elicitation", + "permission" + ] + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "fullAccessOverride": { + "anyOf": [ + { + "type": "string", + "enum": [ + "ask_rule", + "provider_check" + ] + }, + { + "type": "null" + } + ] + }, + "appName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "options": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "decision": { + "type": "string", + "enum": [ + "accept", + "acceptForSession", + "acceptAlways", + "decline", + "cancel" + ] + }, + "label": { + "type": "string" + }, + "warning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "decision", + "label" + ], + "additionalProperties": true + } + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "requestId", + "requestKind" + ], + "additionalProperties": true + }, + "Item14": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "checkpoint" + ] + }, + "checkpointId": { + "type": "string" + }, + "scopeId": { + "type": "string" + }, + "files": { + "$ref": "#/$defs/Files" + }, + "filesTotal": { + "$ref": "#/$defs/FilesTotal" + }, + "filesOmitted": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "checkpointId", + "scopeId", + "files" + ], + "additionalProperties": true + }, + "Item15": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "run_interrupt_request" + ] + }, + "message": { + "type": "string" + }, + "unresponsiveAt": { + "type": "string" + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "message" + ], + "additionalProperties": true + }, + "Item16": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "run_interrupt_result" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "message" + ], + "additionalProperties": true + }, + "Item17": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "system_notice" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "message" + ], + "additionalProperties": true + }, + "Item18": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "error" + ] + }, + "failure": { + "$ref": "#/$defs/Failure" + }, + "retry": { + "anyOf": [ + { + "type": "object", + "properties": { + "attempt": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "maxAttempts": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "retryDelayMs": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "attempt", + "maxAttempts", + "retryDelayMs" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "failure" + ], + "additionalProperties": true + }, + "Item19": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "compaction" + ] + }, + "driver": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "beforeTokenCount": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "afterTokenCount": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "driver" + ], + "additionalProperties": true + }, + "Item2": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "notification" + ] + }, + "source": { + "$ref": "#/$defs/Source2" + }, + "outcome": { + "type": "string", + "enum": [ + "completed", + "failed", + "cancelled", + "updated", + "unknown" + ] + }, + "summary": { + "type": "string" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "source", + "outcome", + "summary" + ], + "additionalProperties": true + }, + "Item20": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "handoff" + ] + }, + "contextHandoffId": { + "type": "string" + }, + "fromProviderThreadIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "toProviderThreadId": { + "type": "string" + }, + "fromProviderInstanceIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "toProviderInstanceId": { + "type": "string" + }, + "fromModelSelections": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/$defs/ModelSelection" + } + }, + { + "type": "null" + } + ] + }, + "toModel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "strategy": { + "type": "string", + "enum": [ + "delta_since_target_last_seen", + "fork_delta_summary", + "full_thread_summary", + "checkpoint_summary", + "manual_context" + ] + }, + "summary": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "contextHandoffId", + "fromProviderThreadIds", + "toProviderThreadId", + "fromProviderInstanceIds", + "toProviderInstanceId", + "strategy" + ], + "additionalProperties": true + }, + "Item21": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "fork" + ] + }, + "source": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "run" + ] + }, + "threadId": { + "type": "string" + }, + "runId": { + "type": "string" + } + }, + "required": [ + "type", + "threadId", + "runId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "node" + ] + }, + "nodeId": { + "type": "string" + } + }, + "required": [ + "type", + "nodeId" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/ForkedFrom" + } + ] + }, + "targetThreadId": { + "type": "string" + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "source", + "targetThreadId" + ], + "additionalProperties": true + }, + "Item22": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "thread_created" + ] + }, + "targetThreadId": { + "type": "string" + }, + "targetRunId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "targetProviderInstanceId": { + "type": "string" + }, + "targetModel": { + "type": "string" + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "targetThreadId", + "targetRunId", + "targetProviderInstanceId", + "targetModel" + ], + "additionalProperties": true + }, + "Item23": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "secret_request" + ] + }, + "label": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "placeholder": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "delivery": { + "anyOf": [ + { + "type": "string", + "enum": [ + "file" + ] + }, + { + "type": "null" + } + ] + }, + "secretStatus": { + "type": "string", + "enum": [ + "pending", + "saved", + "declined", + "cancelled" + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "label", + "reason", + "secretStatus" + ], + "additionalProperties": true + }, + "Item24": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "link_request" + ] + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reason": { + "type": "string" + }, + "environmentId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "requestedAccess": { + "anyOf": [ + { + "type": "string", + "enum": [ + "read-only", + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + { + "type": "null" + } + ] + }, + "linkStatus": { + "type": "string", + "enum": [ + "pending", + "linked", + "declined", + "cancelled", + "failed" + ] + }, + "linkedEnvironmentId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "linkedLabel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "linkedAccess": { + "anyOf": [ + { + "type": "string", + "enum": [ + "read-only", + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + { + "type": "null" + } + ] + }, + "failure": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "reason", + "linkStatus" + ], + "additionalProperties": true + }, + "Item25": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "subagent" + ] + }, + "subagentId": { + "type": "string" + }, + "origin": { + "type": "string", + "enum": [ + "provider_native", + "app_owned" + ] + }, + "driver": { + "type": "string" + }, + "providerInstanceId": { + "type": "string" + }, + "childThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "remoteChild": { + "anyOf": [ + { + "$ref": "#/$defs/RemoteChild" + }, + { + "type": "null" + } + ] + }, + "prompt": { + "type": "string" + }, + "progress": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "result": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "promptOmitted": { + "type": "boolean", + "enum": [ + true + ] + }, + "resultOmitted": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "subagentId", + "origin", + "driver", + "providerInstanceId", + "childThreadId", + "prompt", + "result" + ], + "additionalProperties": true + }, + "Item26": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "dynamic_tool" + ] + }, + "toolName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "viewedImagePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "input": {}, + "output": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "outputOmitted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "toolName", + "input" + ], + "additionalProperties": true + }, + "Item3": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "createdBy": { + "type": "string", + "enum": [ + "user", + "agent", + "system" + ] + }, + "creationSource": { + "type": "string", + "enum": [ + "web", + "mobile", + "mcp", + "provider", + "server" + ] + }, + "type": { + "type": "string", + "enum": [ + "user_message" + ] + }, + "messageId": { + "type": "string" + }, + "scheduledTaskId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "senderThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "idleCompaction": { + "anyOf": [ + { + "type": "string", + "enum": [ + "beforeCacheExpires", + "onReturn" + ] + }, + { + "type": "null" + } + ] + }, + "workflowScript": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "inputIntent": { + "type": "string", + "enum": [ + "turn_start", + "queued_turn", + "steer", + "promoted_queued_to_steer" + ] + }, + "text": { + "type": "string" + }, + "textOmitted": { + "type": "boolean", + "enum": [ + true + ] + }, + "context": { + "anyOf": [ + { + "$ref": "#/$defs/Context" + }, + { + "type": "null" + } + ] + }, + "attachments": { + "$ref": "#/$defs/Attachments" + }, + "modelSwitch": { + "anyOf": [ + { + "type": "object", + "properties": { + "from": { + "$ref": "#/$defs/ModelSelection" + }, + "to": { + "$ref": "#/$defs/ModelSelection" + } + }, + "required": [ + "from", + "to" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "createdBy", + "creationSource", + "type", + "messageId", + "inputIntent", + "text", + "attachments" + ], + "additionalProperties": true + }, + "Item4": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "assistant_message" + ] + }, + "messageId": { + "type": "string" + }, + "text": { + "type": "string" + }, + "textOmitted": { + "type": "boolean", + "enum": [ + true + ] + }, + "attachments": { + "anyOf": [ + { + "$ref": "#/$defs/Attachments" + }, + { + "type": "null" + } + ] + }, + "streaming": { + "type": "boolean" + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "messageId", + "text", + "streaming" + ], + "additionalProperties": true + }, + "Item5": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "reasoning" + ] + }, + "text": { + "type": "string" + }, + "streaming": { + "type": "boolean" + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "text", + "streaming" + ], + "additionalProperties": true + }, + "Item6": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "proposed_plan" + ] + }, + "planId": { + "type": "string" + }, + "markdown": { + "type": "string" + }, + "streaming": { + "type": "boolean" + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "planId", + "markdown", + "streaming" + ], + "additionalProperties": true + }, + "Item7": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "todo_list" + ] + }, + "planId": { + "type": "string" + }, + "steps": { + "$ref": "#/$defs/Steps" + }, + "explanation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "planId", + "steps" + ], + "additionalProperties": true + }, + "Item8": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "user_input_request" + ] + }, + "requestId": { + "type": "string" + }, + "questions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "header": { + "type": "string" + }, + "question": { + "type": "string" + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "description": { + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "preview": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "label", + "description" + ], + "additionalProperties": true + } + }, + "multiSelect": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "allowCustomAnswer": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "initialAnswer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "required": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "header", + "question", + "options" + ], + "additionalProperties": true + } + }, + "questionAnswer": { + "anyOf": [ + { + "type": "object", + "properties": { + "requestId": { + "type": "string" + }, + "questionTextById": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "answers": { + "type": "object" + }, + "attachmentsByQuestionId": { + "$ref": "#/$defs/AttachmentsByQuestionId" + } + }, + "required": [ + "requestId", + "answers", + "attachmentsByQuestionId" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "responseMode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "message" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "requestId", + "questions" + ], + "additionalProperties": true + }, + "Item9": { + "type": "object", + "properties": { + "deferredWork": { + "$ref": "#/$defs/DeferredWork" + }, + "toolNonExecutionKind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolSurface": { + "anyOf": [ + { + "type": "string", + "enum": [ + "browser", + "computer" + ] + }, + { + "type": "null" + } + ] + }, + "toolIcon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + }, + "toolSource": { + "anyOf": [ + { + "$ref": "#/$defs/ToolSource" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "parentItemId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "file_change" + ] + }, + "fileName": { + "type": "string" + }, + "additions": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "deletions": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "diffStr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "oldStr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "newStr": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "changes": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "operation": { + "type": "string" + }, + "path": { + "type": "string" + }, + "oldPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "fileType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mimeType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "operation", + "path" + ], + "additionalProperties": true + } + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "parentItemId", + "ordinal", + "status", + "title", + "startedAt", + "completedAt", + "updatedAt", + "type", + "fileName" + ], + "additionalProperties": true + }, + "Keybindings": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "command": { + "$ref": "#/$defs/Command" + }, + "shortcut": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "metaKey": { + "type": "boolean" + }, + "ctrlKey": { + "type": "boolean" + }, + "shiftKey": { + "type": "boolean" + }, + "altKey": { + "type": "boolean" + }, + "modKey": { + "type": "boolean" + } + }, + "required": [ + "key", + "metaKey", + "ctrlKey", + "shiftKey", + "altKey", + "modKey" + ], + "additionalProperties": true + }, + "whenAst": { + "anyOf": [ + { + "$ref": "#/$defs/Union_" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "command", + "shortcut" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "KeybindingsConfigParseErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "KeybindingsConfigParseError" + ] + }, + "configPath": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "configPath", + "detail" + ], + "additionalProperties": true + }, + "LastError": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Some" + ] + }, + "value": { + "type": "string" + } + }, + "required": [ + "_tag", + "value" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "None" + ] + } + }, + "required": [ + "_tag" + ], + "additionalProperties": true + } + ] + }, + "LimitRecovery": { + "type": "object", + "properties": { + "requestId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "runId": { + "type": "string" + }, + "resetAt": { + "type": "string" + }, + "autoResume": { + "type": "boolean" + }, + "snooze": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "runId", + "resetAt", + "autoResume" + ], + "additionalProperties": true + }, + "Lineage": { + "type": "object", + "properties": { + "parentThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "relationshipToParent": { + "anyOf": [ + { + "type": "string", + "enum": [ + "fork", + "subagent" + ] + }, + { + "type": "null" + } + ] + }, + "rootThreadId": { + "type": "string" + }, + "sideChat": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "parentThreadId", + "relationshipToParent", + "rootThreadId" + ], + "additionalProperties": true + }, + "LinkOrigin": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "label": { + "type": "string" + }, + "access": { + "anyOf": [ + { + "type": "string", + "enum": [ + "read-only", + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "sessionId", + "label" + ], + "additionalProperties": true + }, + "LinkedPullRequest": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "url": { + "type": "string" + } + }, + "required": [ + "projectId", + "repository", + "number", + "url" + ], + "additionalProperties": true + }, + "Local": { + "type": "object", + "properties": { + "isRepo": { + "type": "boolean" + }, + "sourceControlProvider": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "name": { + "type": "string" + }, + "baseUrl": { + "type": "string" + } + }, + "required": [ + "kind", + "name", + "baseUrl" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "hasPrimaryRemote": { + "type": "boolean" + }, + "isDefaultRef": { + "type": "boolean" + }, + "refName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasWorkingTreeChanges": { + "type": "boolean" + }, + "workingTree": { + "$ref": "#/$defs/WorkingTree" + }, + "branchChanges": { + "anyOf": [ + { + "$ref": "#/$defs/BranchChanges" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "isRepo", + "hasPrimaryRemote", + "isDefaultRef", + "refName", + "hasWorkingTreeChanges", + "workingTree" + ], + "additionalProperties": true + }, + "McpAppRequestErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "McpAppRequestError" + ] + }, + "threadId": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": [ + "not-an-app", + "provider-unsupported", + "session-stopped", + "tool-not-callable", + "unsupported-content", + "request-failed" + ] + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "threadId", + "reason" + ], + "additionalProperties": true + }, + "McpAppsCallToolError": { + "anyOf": [ + { + "$ref": "#/$defs/McpAppRequestErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "McpAppsCallToolPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "arguments": { + "type": "object" + } + }, + "required": [ + "threadId", + "itemId", + "name", + "arguments" + ], + "additionalProperties": true + }, + "McpAppsCallToolSuccess": { + "type": "object", + "properties": { + "content": { + "type": "array" + }, + "structuredContent": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "isError": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "_meta": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "content" + ], + "additionalProperties": true + }, + "McpAppsReadResourcePayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "threadId", + "itemId", + "uri" + ], + "additionalProperties": true + }, + "McpAppsReadResourceSuccess": { + "type": "object", + "properties": { + "contents": { + "type": "array" + } + }, + "required": [ + "contents" + ], + "additionalProperties": true + }, + "McpAppsToolInfoPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "threadId", + "itemId", + "name" + ], + "additionalProperties": true + }, + "McpAppsToolInfoSuccess": { + "type": "object", + "properties": { + "callable": { + "type": "boolean" + }, + "readOnly": { + "type": "boolean" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "tool": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "callable", + "readOnly" + ], + "additionalProperties": true + }, + "McpAppsUpdateModelContextPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "conversationThreadId": { + "type": "string" + }, + "content": { + "anyOf": [ + { + "type": "array" + }, + { + "type": "null" + } + ] + }, + "structuredContent": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "threadId", + "itemId", + "conversationThreadId" + ], + "additionalProperties": true + }, + "ModelFilter": { + "type": "object", + "properties": { + "include": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + } + }, + "required": [ + "model" + ], + "additionalProperties": true + } + }, + { + "type": "null" + } + ] + }, + "exclude": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "type": "string" + } + }, + "required": [ + "model" + ], + "additionalProperties": true + } + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + "ModelSelection": { + "type": "object", + "properties": { + "provider": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "instanceId": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "model": {}, + "options": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "model" + ], + "additionalProperties": true + }, + "NativeItemRef": { + "type": "object", + "properties": { + "driver": { + "type": "string" + }, + "nativeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "strength": { + "type": "string", + "enum": [ + "strong", + "weak", + "none" + ] + }, + "fingerprint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "driver", + "nativeId", + "strength" + ], + "additionalProperties": true + }, + "NativeMetadata": { + "type": "object", + "properties": { + "modelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "itemIdentityVersion": { + "anyOf": [ + { + "type": "number", + "enum": [ + 2 + ] + }, + { + "type": "null" + } + ] + }, + "forkHeadMessageId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "claudeAttachmentBudget": { + "anyOf": [ + { + "type": "object", + "properties": { + "bytesUsed": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "pagesUsed": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "bytesUsed", + "pagesUsed" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + "NavStatus": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Idle" + ] + } + }, + "required": [ + "_tag" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Loading" + ] + }, + "url": { + "type": "string" + }, + "title": { + "type": "string", + "maxLength": 512 + } + }, + "required": [ + "_tag", + "url", + "title" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Success" + ] + }, + "url": { + "type": "string" + }, + "title": { + "type": "string", + "maxLength": 512 + } + }, + "required": [ + "_tag", + "url", + "title" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "LoadFailed" + ] + }, + "url": { + "type": "string" + }, + "title": { + "type": "string", + "maxLength": 512 + }, + "code": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "description": { + "type": "string" + }, + "download": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "fileName": { + "type": "string" + } + }, + "required": [ + "id", + "fileName" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "url", + "title", + "code", + "description" + ], + "additionalProperties": true + } + ] + }, + "Notification": { + "type": "object", + "properties": { + "source": { + "$ref": "#/$defs/Source2" + }, + "outcome": { + "type": "string", + "enum": [ + "completed", + "failed", + "cancelled", + "updated", + "unknown" + ] + }, + "summary": { + "type": "string" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "source", + "outcome", + "summary" + ], + "additionalProperties": true + }, + "Objects_": { + "type": "object", + "properties": { + "role": { + "type": "string" + }, + "name": { + "type": "string" + }, + "value": { + "type": "string" + }, + "description": { + "type": "string" + }, + "bounds": { + "anyOf": [ + { + "type": "object", + "properties": { + "x": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "y": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "width": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "height": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "x", + "y", + "width", + "height" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "state": { + "type": "object", + "properties": { + "active": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "busy": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "checked": { + "anyOf": [ + { + "type": "string", + "enum": [ + "on", + "off", + "mixed" + ] + }, + { + "type": "null" + } + ] + }, + "editable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "expanded": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "focused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "selected": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "visible": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + "actions": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 32 + }, + "children": { + "type": "array", + "items": { + "$ref": "#/$defs/Objects_" + }, + "maxItems": 10000 + } + }, + "required": [ + "role", + "bounds", + "children" + ], + "additionalProperties": true + }, + "Opens": { + "type": "object", + "properties": { + "openedAt": { + "type": "number" + }, + "score": { + "type": "number", + "minimum": 0 + }, + "picks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "query": { + "type": "string", + "maxLength": 32 + }, + "at": { + "type": "number" + } + }, + "required": [ + "query", + "at" + ], + "additionalProperties": true + }, + "maxItems": 3 + } + }, + "required": [ + "openedAt", + "score", + "picks" + ], + "additionalProperties": true + }, + "OptionDescriptors": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "select" + ] + }, + "options": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "isDefault": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "label" + ], + "additionalProperties": true + } + }, + "currentValue": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "promptInjectedValues": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "role": { + "anyOf": [ + { + "type": "string", + "enum": [ + "reasoning" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "label", + "type", + "options" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "boolean" + ] + }, + "currentValue": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "label", + "type" + ], + "additionalProperties": true + } + ] + } + }, + "OrchestrationDispatchCommandError": { + "anyOf": [ + { + "$ref": "#/$defs/OrchestrationV2DispatchCommandErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "OrchestrationDispatchCommandErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "OrchestrationDispatchCommandError" + ] + }, + "message": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "bootstrapThreadDisposition": { + "anyOf": [ + { + "type": "string", + "enum": [ + "deleted", + "not-created" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": true + }, + "OrchestrationDispatchCommandPayload": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.stop" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "stopDelegatedTasks": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "threadId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.create" + ] + }, + "createdBy": { + "type": "string", + "enum": [ + "user", + "agent", + "system" + ] + }, + "creationSource": { + "type": "string", + "enum": [ + "web", + "mobile", + "mcp", + "provider", + "server" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "modelSelection": { + "$ref": "#/$defs/ModelSelection" + }, + "runtimeMode": { + "type": "string", + "enum": [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + "interactionMode": { + "type": "string", + "enum": [ + "default", + "plan" + ] + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "worktreePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "linkOrigin": { + "anyOf": [ + { + "$ref": "#/$defs/LinkOrigin" + }, + { + "type": "null" + } + ] + }, + "delegatedFrom": { + "anyOf": [ + { + "type": "object", + "properties": { + "environmentId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "environmentId", + "threadId", + "title" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "importedNativeThread": { + "anyOf": [ + { + "type": "object", + "properties": { + "ref": { + "type": "object", + "properties": { + "driver": { + "type": "string" + }, + "nativeId": { + "type": "string" + }, + "strength": { + "type": "string", + "enum": [ + "strong" + ] + } + }, + "required": [ + "driver", + "nativeId", + "strength" + ], + "additionalProperties": true + }, + "metadata": { + "anyOf": [ + { + "$ref": "#/$defs/NativeMetadata" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "ref" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "createdBy", + "creationSource", + "commandId", + "threadId", + "projectId", + "title", + "modelSelection", + "runtimeMode", + "interactionMode", + "branch", + "worktreePath" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.archive" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.guest-share.close" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.guest-message.dismiss" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "guestMessageId": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "guestMessageId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.unarchive" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.delete" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "onlyIfUnused": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "threadId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.settle" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "settledAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "threadId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.auto-settle" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "snapshotAt": { + "type": "string" + }, + "settledAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "threadId", + "snapshotAt" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.unsettle" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": [ + "user" + ] + } + }, + "required": [ + "type", + "commandId", + "threadId", + "reason" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.snooze" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "snoozedUntil": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "snoozedUntil" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.unsnooze" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": [ + "user" + ] + } + }, + "required": [ + "type", + "commandId", + "threadId", + "reason" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.auto-settle.set" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "enabled" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.pin" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "orderKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "threadId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.unpin" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.tag.set" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "tagged": { + "type": "boolean" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "tag", + "tagged" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.pin.reorder" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "orderKey": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "orderKey" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.active.reorder" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "orderKey": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "orderKey" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.visit" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "visitedAt": { + "type": "string" + }, + "opened": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "searchQuery": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "threadId", + "visitedAt" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.mark-unread" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.metadata.update" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "regenerateTitle": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "worktreePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expectedWorktreePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expectedBranch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expectedEmpty": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "limitRecovery": { + "anyOf": [ + { + "type": "object", + "properties": { + "runId": { + "type": "string" + }, + "resetAt": { + "type": "string" + }, + "autoResume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "snooze": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "runId", + "resetAt" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "linkedPullRequest": { + "anyOf": [ + { + "$ref": "#/$defs/LinkedPullRequest" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "threadId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.pull-request.link" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "host": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "url": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "manual", + "created", + "agent", + "stack", + "stack-dismissed" + ] + }, + "repositoryWebUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "threadId", + "host", + "repository", + "number", + "url", + "source" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.pull-request.unlink" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "host": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "type", + "commandId", + "threadId", + "host", + "repository", + "number" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.pull-request-link.sync" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "host": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "snapshot": { + "$ref": "#/$defs/Snapshot" + }, + "stack": { + "anyOf": [ + { + "$ref": "#/$defs/Stack" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "threadId", + "host", + "repository", + "number", + "snapshot", + "stack" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.pull-request.watch" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "host": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "watching": { + "type": "boolean" + }, + "instructions": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "link": { + "anyOf": [ + { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "manual", + "created", + "agent", + "stack", + "stack-dismissed" + ] + }, + "repositoryWebUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "url", + "source" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "threadId", + "host", + "repository", + "number", + "watching" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.pull-request.sync" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "snapshotSequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "expected": { + "type": "object", + "properties": { + "workspaceRoot": { + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "worktreePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "linkedPullRequest": { + "anyOf": [ + { + "$ref": "#/$defs/LinkedPullRequest" + }, + { + "type": "null" + } + ] + }, + "branchPullRequest": { + "anyOf": [ + { + "$ref": "#/$defs/LinkedPullRequest" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "workspaceRoot", + "branch", + "worktreePath", + "linkedPullRequest", + "branchPullRequest" + ], + "additionalProperties": true + }, + "branchPullRequest": { + "anyOf": [ + { + "$ref": "#/$defs/LinkedPullRequest" + }, + { + "type": "null" + } + ] + }, + "linkedPullRequest": { + "anyOf": [ + { + "$ref": "#/$defs/LinkedPullRequest" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "threadId", + "projectId", + "snapshotSequence", + "expected", + "branchPullRequest" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.title.regeneration.complete" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "requestId": { + "type": "string" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "threadId", + "requestId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.runtime-mode.set" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runtimeMode": { + "type": "string", + "enum": [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + } + }, + "required": [ + "type", + "commandId", + "threadId", + "runtimeMode" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.interaction-mode.set" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "interactionMode": { + "type": "string", + "enum": [ + "default", + "plan" + ] + } + }, + "required": [ + "type", + "commandId", + "threadId", + "interactionMode" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.model-selection.set" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "modelSelection": { + "$ref": "#/$defs/ModelSelection" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "modelSelection" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "provider-session.detach" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "providerSessionId": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "threadId", + "providerSessionId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "message.dispatch" + ] + }, + "notification": { + "anyOf": [ + { + "$ref": "#/$defs/Notification" + }, + { + "type": "null" + } + ] + }, + "guestMessageId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdBy": { + "type": "string", + "enum": [ + "user", + "agent", + "system" + ] + }, + "creationSource": { + "type": "string", + "enum": [ + "web", + "mobile", + "mcp", + "provider", + "server" + ] + }, + "scheduledTaskId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "senderThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "idleCompaction": { + "anyOf": [ + { + "type": "string", + "enum": [ + "beforeCacheExpires", + "onReturn" + ] + }, + { + "type": "null" + } + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "messageId": { + "type": "string" + }, + "text": { + "type": "string" + }, + "context": { + "anyOf": [ + { + "$ref": "#/$defs/Context" + }, + { + "type": "null" + } + ] + }, + "attachments": { + "$ref": "#/$defs/Attachments" + }, + "titleSeed": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "modelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "sourcePlanRef": { + "anyOf": [ + { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "planId": { + "type": "string" + } + }, + "required": [ + "threadId", + "planId" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "restartContinuationOfRunId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "usageLimitContinuationOfRunId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "manualContinuationOfRunId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "usageLimitRecoveryRequestId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deliveryIntent": { + "anyOf": [ + { + "type": "string", + "enum": [ + "auto", + "steer", + "restart" + ] + }, + { + "type": "null" + } + ] + }, + "delegatedCompletion": { + "anyOf": [ + { + "$ref": "#/$defs/DelegatedCompletion" + }, + { + "type": "null" + } + ] + }, + "dispatchMode": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "defer_start" + ] + }, + "workspaceStrategy": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "root" + ] + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/WorkspaceStrategy" + }, + { + "$ref": "#/$defs/WorkspaceStrategy2" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "steer_active" + ] + }, + "targetRunId": { + "type": "string" + } + }, + "required": [ + "type", + "targetRunId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "restart_active" + ] + }, + "targetRunId": { + "type": "string" + } + }, + "required": [ + "type", + "targetRunId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "queue_after_active" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "start_immediately" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": true + } + ] + } + }, + "required": [ + "type", + "createdBy", + "creationSource", + "commandId", + "threadId", + "messageId", + "text", + "attachments", + "dispatchMode" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "prepared-run.release" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "runId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "notification.delivery.accept" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "messageId": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "messageId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "prepared-run.progress" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "type": "string" + }, + "phase": { + "type": "string", + "enum": [ + "worktree", + "setup" + ] + } + }, + "required": [ + "type", + "commandId", + "threadId", + "runId", + "phase" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "prepared-run.fail" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "type": "string" + }, + "failure": { + "$ref": "#/$defs/Failure" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "runId", + "failure" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "prepared-run.retry" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "runId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "subagent.stop" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "type": "string" + }, + "subagentId": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "runId", + "subagentId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "run.interrupt" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "holdQueue": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "threadId", + "runId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "queued-message.promote-to-steer" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "queuedRunId": { + "type": "string" + }, + "targetRunId": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "queuedRunId", + "targetRunId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "queue.resume" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "queued-run.start" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "runId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "queued-run.reorder" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "type": "string" + }, + "beforeRunId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "threadId", + "runId", + "beforeRunId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "queued-run.cancel" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "runId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "queued-run.edit" + ] + }, + "context": { + "anyOf": [ + { + "$ref": "#/$defs/Context" + }, + { + "type": "null" + } + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "type": "string" + }, + "text": { + "type": "string" + }, + "attachments": { + "anyOf": [ + { + "$ref": "#/$defs/Attachments" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "threadId", + "runId", + "text" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "runtime-request.respond" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "requestId": { + "type": "string" + }, + "decision": { + "anyOf": [ + { + "type": "string", + "enum": [ + "accept", + "acceptForSession", + "acceptAlways", + "decline", + "cancel" + ] + }, + { + "type": "null" + } + ] + }, + "feedback": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "answers": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + }, + "attachmentsByQuestionId": { + "anyOf": [ + { + "$ref": "#/$defs/AttachmentsByQuestionId" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "threadId", + "requestId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.user-input.dismiss" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "requestId": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "requestId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "checkpoint.rollback" + ] + }, + "restoreFiles": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "scopeId": { + "type": "string" + }, + "checkpointId": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "scopeId", + "checkpointId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.fork" + ] + }, + "createdBy": { + "type": "string", + "enum": [ + "user", + "agent", + "system" + ] + }, + "creationSource": { + "type": "string", + "enum": [ + "web", + "mobile", + "mcp", + "provider", + "server" + ] + }, + "commandId": { + "type": "string" + }, + "sourceThreadId": { + "type": "string" + }, + "targetThreadId": { + "type": "string" + }, + "sourcePoint": { + "$ref": "#/$defs/SourcePoint" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sideChat": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "createdBy", + "creationSource", + "commandId", + "sourceThreadId", + "targetThreadId", + "sourcePoint" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.merge_back" + ] + }, + "createdBy": { + "type": "string", + "enum": [ + "user", + "agent", + "system" + ] + }, + "creationSource": { + "type": "string", + "enum": [ + "web", + "mobile", + "mcp", + "provider", + "server" + ] + }, + "commandId": { + "type": "string" + }, + "sourceThreadId": { + "type": "string" + }, + "targetThreadId": { + "type": "string" + }, + "sourcePoint": { + "$ref": "#/$defs/SourcePoint" + }, + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "createdBy", + "creationSource", + "commandId", + "sourceThreadId", + "targetThreadId", + "sourcePoint" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "delegated_task.request" + ] + }, + "createdBy": { + "type": "string", + "enum": [ + "user", + "agent", + "system" + ] + }, + "creationSource": { + "type": "string", + "enum": [ + "web", + "mobile", + "mcp", + "provider", + "server" + ] + }, + "commandId": { + "type": "string" + }, + "parentThreadId": { + "type": "string" + }, + "parentRunId": { + "type": "string" + }, + "parentNodeId": { + "type": "string" + }, + "task": { + "type": "string" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "modelSelection": { + "$ref": "#/$defs/ModelSelection" + }, + "runtimeMode": { + "type": "string", + "enum": [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + "interactionMode": { + "type": "string", + "enum": [ + "default", + "plan" + ] + }, + "workspaceStrategy": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "root" + ] + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/WorkspaceStrategy" + }, + { + "$ref": "#/$defs/WorkspaceStrategy2" + }, + { + "type": "null" + } + ] + }, + "completionWake": { + "anyOf": [ + { + "type": "string", + "enum": [ + "always", + "settled_only" + ] + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "createdBy", + "creationSource", + "commandId", + "parentThreadId", + "parentRunId", + "parentNodeId", + "task", + "modelSelection", + "runtimeMode", + "interactionMode" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "delegated_task.wake-policy" + ] + }, + "commandId": { + "type": "string" + }, + "parentThreadId": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "completionWake": { + "type": "string", + "enum": [ + "always", + "settled_only" + ] + } + }, + "required": [ + "type", + "commandId", + "parentThreadId", + "taskId", + "completionWake" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "delegated_task.completion-delivery.acknowledge" + ] + }, + "commandId": { + "type": "string" + }, + "parentThreadId": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "observedByRunId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "parentThreadId", + "taskId", + "observedByRunId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "delegated_task.completion-delivery.dispose" + ] + }, + "commandId": { + "type": "string" + }, + "parentThreadId": { + "type": "string" + }, + "taskId": { + "type": "string" + } + }, + "required": [ + "type", + "commandId", + "parentThreadId", + "taskId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread.created.record" + ] + }, + "commandId": { + "type": "string" + }, + "parentThreadId": { + "type": "string" + }, + "parentRunId": { + "type": "string" + }, + "parentNodeId": { + "type": "string" + }, + "targetThreadId": { + "type": "string" + }, + "targetRunId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "parentThreadId", + "parentRunId", + "parentNodeId", + "targetThreadId", + "targetRunId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "provider.switch" + ] + }, + "commandId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "modelSelection": { + "$ref": "#/$defs/ModelSelection" + } + }, + "required": [ + "type", + "commandId", + "threadId", + "modelSelection" + ], + "additionalProperties": true + } + ] + }, + "OrchestrationDispatchCommandSuccess": { + "type": "object", + "properties": { + "sequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "sequence" + ], + "additionalProperties": true + }, + "OrchestrationForceStopThreadError": { + "anyOf": [ + { + "$ref": "#/$defs/OrchestrationV2ForceStopThreadErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "OrchestrationForceStopThreadPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "runId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "additionalProperties": true + }, + "OrchestrationForceStopThreadSuccess": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "killed", + "released", + "releasing", + "shared" + ] + }, + "sharedWithThreadIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "outcome", + "sharedWithThreadIds" + ], + "additionalProperties": true + }, + "OrchestrationGetArchivedShellSnapshotError": { + "anyOf": [ + { + "$ref": "#/$defs/OrchestrationV2GetShellSnapshotErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "OrchestrationGetArchivedShellSnapshotSuccess": { + "type": "object", + "properties": { + "schemaVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "snapshotSequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "projects": { + "$ref": "#/$defs/Projects" + }, + "threads": { + "$ref": "#/$defs/Threads" + } + }, + "required": [ + "schemaVersion", + "snapshotSequence", + "projects", + "threads" + ], + "additionalProperties": true + }, + "OrchestrationGetFullThreadDiffError": { + "anyOf": [ + { + "$ref": "#/$defs/OrchestrationGetFullThreadDiffErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "OrchestrationGetFullThreadDiffErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "OrchestrationGetFullThreadDiffError" + ] + }, + "message": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": true + }, + "OrchestrationGetFullThreadDiffPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "toTurnCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "ignoreWhitespace": { + "type": "boolean" + } + }, + "required": [ + "threadId", + "toTurnCount" + ], + "additionalProperties": true + }, + "OrchestrationGetNativeChildWorkError": { + "anyOf": [ + { + "$ref": "#/$defs/OrchestrationV2GetNativeChildWorkErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "OrchestrationGetNativeChildWorkSuccess": { + "type": "object", + "properties": { + "children": { + "type": "array", + "items": { + "type": "object", + "properties": { + "conversationId": { + "type": "string" + }, + "role": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "steps": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "type": { + "type": "string" + }, + "status": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "text": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "toolCalls": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "input": { + "type": "string" + } + }, + "required": [ + "name", + "input" + ], + "additionalProperties": true + } + } + }, + "required": [ + "index", + "type", + "status", + "text", + "toolCalls" + ], + "additionalProperties": true + } + }, + { + "type": "null" + } + ] + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "conversationId", + "role", + "prompt", + "steps", + "truncated" + ], + "additionalProperties": true + } + } + }, + "required": [ + "children" + ], + "additionalProperties": true + }, + "OrchestrationGetOlderSettledThreadsPayload": { + "type": "object", + "properties": { + "before": { + "type": "object", + "properties": { + "settledAt": { + "type": "string" + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "settledAt" + ], + "additionalProperties": true + }, + "projectIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "limit": { + "type": "integer", + "minimum": 0, + "maximum": 50 + } + }, + "required": [ + "before", + "limit" + ], + "additionalProperties": true + }, + "OrchestrationGetOlderSettledThreadsSuccess": { + "type": "object", + "properties": { + "snapshotSequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "threads": { + "$ref": "#/$defs/Threads" + }, + "counts": { + "$ref": "#/$defs/Counts" + } + }, + "required": [ + "snapshotSequence", + "threads", + "counts" + ], + "additionalProperties": true + }, + "OrchestrationGetThreadFamilySuccess": { + "type": "object", + "properties": { + "snapshotSequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "threads": { + "$ref": "#/$defs/Threads" + } + }, + "required": [ + "snapshotSequence", + "threads" + ], + "additionalProperties": true + }, + "OrchestrationGetThreadProjectionError": { + "anyOf": [ + { + "$ref": "#/$defs/OrchestrationV2GetThreadProjectionErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "OrchestrationGetThreadProjectionSuccess": { + "type": "object", + "properties": { + "thread": { + "type": "object", + "properties": { + "createdBy": { + "type": "string", + "enum": [ + "user", + "agent", + "system" + ] + }, + "creationSource": { + "type": "string", + "enum": [ + "web", + "mobile", + "mcp", + "provider", + "server" + ] + }, + "id": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "providerInstanceId": { + "type": "string" + }, + "modelSelection": { + "$ref": "#/$defs/ModelSelection" + }, + "runtimeMode": { + "type": "string", + "enum": [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + "interactionMode": { + "type": "string", + "enum": [ + "default", + "plan" + ] + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "worktreePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "linkedPullRequest": { + "anyOf": [ + { + "$ref": "#/$defs/LinkedPullRequest" + }, + { + "type": "null" + } + ] + }, + "pullRequests": { + "anyOf": [ + { + "$ref": "#/$defs/PullRequests" + }, + { + "type": "null" + } + ] + }, + "branchPullRequest": { + "anyOf": [ + { + "$ref": "#/$defs/LinkedPullRequest" + }, + { + "type": "null" + } + ] + }, + "activeProviderThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "historyOrigin": { + "anyOf": [ + { + "type": "string", + "enum": [ + "native", + "v1_import" + ] + }, + { + "type": "null" + } + ] + }, + "linkOrigin": { + "anyOf": [ + { + "$ref": "#/$defs/LinkOrigin" + }, + { + "type": "null" + } + ] + }, + "delegatedFrom": { + "anyOf": [ + { + "type": "object", + "properties": { + "environmentId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "environmentId", + "threadId", + "title" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "lineage": { + "$ref": "#/$defs/Lineage" + }, + "forkedFrom": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "run" + ] + }, + "threadId": { + "type": "string" + }, + "runId": { + "type": "string" + } + }, + "required": [ + "type", + "threadId", + "runId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "node" + ] + }, + "nodeId": { + "type": "string" + } + }, + "required": [ + "type", + "nodeId" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/ForkedFrom" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "archivedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "settledOverride": { + "anyOf": [ + { + "type": "string", + "enum": [ + "settled", + "active" + ] + }, + { + "type": "null" + } + ] + }, + "settledAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "unsettledAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "snoozedUntil": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "snoozedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "lastSnoozeWakeAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "limitRecovery": { + "anyOf": [ + { + "$ref": "#/$defs/LimitRecovery" + }, + { + "type": "null" + } + ] + }, + "guestShare": { + "anyOf": [ + { + "$ref": "#/$defs/GuestShare" + }, + { + "type": "null" + } + ] + }, + "pinnedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "autoSettleDisabledAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "pinOrderKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "activeOrderKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "tags": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "lastVisitedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "opens": { + "anyOf": [ + { + "$ref": "#/$defs/Opens" + }, + { + "type": "null" + } + ] + }, + "titleRegeneration": { + "anyOf": [ + { + "type": "object", + "properties": { + "requestId": { + "type": "string" + }, + "startedAt": { + "type": "string" + } + }, + "required": [ + "requestId", + "startedAt" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "rollbackRequestId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "rollbackFailure": { + "anyOf": [ + { + "type": "object", + "properties": { + "requestId": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "requestId", + "message" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "deletedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "createdBy", + "creationSource", + "id", + "projectId", + "title", + "providerInstanceId", + "modelSelection", + "runtimeMode", + "interactionMode", + "branch", + "worktreePath", + "activeProviderThreadId", + "lineage", + "forkedFrom", + "createdAt", + "updatedAt", + "archivedAt", + "deletedAt" + ], + "additionalProperties": true + }, + "runs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "ordinal": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "providerInstanceId": { + "type": "string" + }, + "modelSelection": { + "$ref": "#/$defs/ModelSelection" + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "userMessageId": { + "type": "string" + }, + "rootNodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "activeAttemptId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "preparing", + "queued", + "starting", + "running", + "waiting", + "completed", + "interrupted", + "failed", + "cancelled", + "rolled_back" + ] + }, + "queuePosition": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "queueHeld": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "requestedAt": { + "type": "string" + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "checkpointId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "contextHandoffId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "restartContinuationOfRunId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "delegatedTaskId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workStartedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "restartCancelledBackgroundWork": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "subagent", + "shell", + "monitor", + "task" + ] + }, + "label": { + "type": "string" + }, + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "awaited": { + "anyOf": [ + { + "type": "boolean", + "enum": [ + true + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "label" + ], + "additionalProperties": true + } + }, + { + "type": "null" + } + ] + }, + "sourcePlanRef": { + "anyOf": [ + { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "planId": { + "type": "string" + } + }, + "required": [ + "threadId", + "planId" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "delegatedCompletion": { + "anyOf": [ + { + "type": "object", + "properties": { + "disposition": { + "type": "string", + "enum": [ + "open", + "stopped", + "disposed" + ] + }, + "nextGeneration": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "delivery": { + "anyOf": [ + { + "type": "object", + "properties": { + "generation": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "messageId": { + "type": "string" + }, + "taskIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "generation", + "messageId", + "taskIds" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "disposition", + "nextGeneration", + "delivery" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "workspacePreparation": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "root" + ] + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/WorkspaceStrategy" + }, + { + "$ref": "#/$defs/WorkspaceStrategy2" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "threadId", + "ordinal", + "providerInstanceId", + "modelSelection", + "providerThreadId", + "userMessageId", + "rootNodeId", + "activeAttemptId", + "status", + "requestedAt", + "startedAt", + "completedAt", + "checkpointId", + "contextHandoffId" + ], + "additionalProperties": true + } + }, + "attempts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "nativeThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "runId": { + "type": "string" + }, + "attemptOrdinal": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "rootNodeId": { + "type": "string" + }, + "providerInstanceId": { + "type": "string" + }, + "providerThreadId": { + "type": "string" + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reason": { + "type": "string", + "enum": [ + "initial", + "steering_restart", + "retry", + "provider_recovery" + ] + }, + "status": { + "type": "string", + "enum": [ + "pending", + "running", + "completed", + "interrupted", + "failed", + "cancelled", + "superseded" + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "runId", + "attemptOrdinal", + "rootNodeId", + "providerInstanceId", + "providerThreadId", + "providerTurnId", + "reason", + "status", + "startedAt", + "completedAt" + ], + "additionalProperties": true + } + }, + "nodes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "parentNodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "rootNodeId": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "root_turn", + "assistant_message", + "reasoning", + "plan", + "todo_list", + "tool_call", + "approval_request", + "user_input_request", + "subagent", + "hook", + "system" + ] + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "interrupted", + "failed", + "cancelled", + "rolled_back" + ] + }, + "countsForRun": { + "type": "boolean" + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeItemRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "runtimeRequestId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "checkpointScopeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "parentNodeId", + "rootNodeId", + "kind", + "status", + "countsForRun", + "providerThreadId", + "providerTurnId", + "nativeItemRef", + "runtimeRequestId", + "checkpointScopeId", + "startedAt", + "completedAt" + ], + "additionalProperties": true + } + }, + "subagents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "parentNodeId": { + "type": "string" + }, + "origin": { + "type": "string", + "enum": [ + "provider_native", + "app_owned" + ] + }, + "createdBy": { + "type": "string", + "enum": [ + "user", + "agent", + "system" + ] + }, + "driver": { + "type": "string" + }, + "providerInstanceId": { + "type": "string" + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "childThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "remoteChild": { + "anyOf": [ + { + "$ref": "#/$defs/RemoteChild" + }, + { + "type": "null" + } + ] + }, + "nativeTaskRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "prompt": { + "type": "string" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "modelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "completionWake": { + "anyOf": [ + { + "type": "string", + "enum": [ + "always", + "settled_only" + ] + }, + { + "type": "null" + } + ] + }, + "completionDelivery": { + "anyOf": [ + { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "pending", + "claimed", + "acknowledged", + "delivered", + "disposed" + ] + }, + "observedByRunId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "state", + "observedByRunId" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "idle", + "pending", + "running", + "waiting", + "completed", + "failed", + "cancelled", + "interrupted" + ] + }, + "progress": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workflow": { + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "runHandles": { + "anyOf": [ + { + "type": "object", + "properties": { + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "transcriptDir": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "scriptPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "phases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "title": { + "type": "string" + } + }, + "required": [ + "index", + "title" + ], + "additionalProperties": true + } + }, + "agents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "index": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "label": { + "type": "string" + }, + "agentId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "childThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "state": { + "type": "string", + "enum": [ + "queued", + "running", + "completed", + "failed" + ] + }, + "phaseIndex": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "phaseTitle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "effort": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "speed": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "attempt": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "totalTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "toolCalls": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "durationMs": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "queuedAt": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "prompt": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "result": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "index", + "label", + "state" + ], + "additionalProperties": true + } + }, + "totalTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "toolCalls": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "durationMs": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "phases", + "agents" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "result": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "promptOmitted": { + "type": "boolean", + "enum": [ + true + ] + }, + "resultOmitted": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "parentNodeId", + "origin", + "createdBy", + "driver", + "providerInstanceId", + "providerThreadId", + "childThreadId", + "nativeTaskRef", + "prompt", + "title", + "model", + "status", + "result", + "startedAt", + "completedAt", + "updatedAt" + ], + "additionalProperties": true + } + }, + "providerSessions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "driver": { + "type": "string" + }, + "providerInstanceId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "starting", + "ready", + "running", + "waiting", + "stopped", + "error" + ] + }, + "cwd": { + "type": "string" + }, + "model": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "capabilities": { + "type": "object", + "properties": { + "sessions": { + "type": "object", + "properties": { + "supportsMultipleProviderThreadsPerSession": { + "type": "boolean" + }, + "supportsModelSwitchInSession": { + "type": "boolean" + }, + "supportsProviderSwitchingViaHandoff": { + "type": "boolean" + }, + "supportsRuntimeModeSwitchInSession": { + "type": "boolean" + }, + "pendingRequestsSurviveRestart": { + "type": "boolean" + } + }, + "required": [ + "supportsMultipleProviderThreadsPerSession", + "supportsModelSwitchInSession", + "supportsProviderSwitchingViaHandoff", + "supportsRuntimeModeSwitchInSession", + "pendingRequestsSurviveRestart" + ], + "additionalProperties": true + }, + "threads": { + "type": "object", + "properties": { + "canCreateEmptyThread": { + "type": "boolean" + }, + "canReadThreadSnapshot": { + "type": "boolean" + }, + "canRollbackThread": { + "type": "boolean" + }, + "canForkThread": { + "type": "boolean" + }, + "canForkFromTurn": { + "type": "boolean" + }, + "canForkFromSubagentThread": { + "type": "boolean" + }, + "exposesNativeThreadId": { + "type": "boolean" + } + }, + "required": [ + "canCreateEmptyThread", + "canReadThreadSnapshot", + "canRollbackThread", + "canForkThread", + "canForkFromTurn", + "canForkFromSubagentThread", + "exposesNativeThreadId" + ], + "additionalProperties": true + }, + "turns": { + "type": "object", + "properties": { + "exposesNativeTurnId": { + "type": "boolean" + }, + "emitsTurnStarted": { + "type": "boolean" + }, + "emitsTurnCompleted": { + "type": "boolean" + }, + "supportsInterrupt": { + "type": "boolean" + }, + "supportsActiveSteering": { + "type": "boolean" + }, + "activeSteeringInterruptsTools": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "activeSteeringAcceptsAttachments": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "supportsSteeringByInterruptRestart": { + "type": "boolean" + }, + "supportsQueuedMessages": { + "type": "boolean" + }, + "terminalStatusQuality": { + "type": "string", + "enum": [ + "strong", + "weak", + "none" + ] + } + }, + "required": [ + "exposesNativeTurnId", + "emitsTurnStarted", + "emitsTurnCompleted", + "supportsInterrupt", + "supportsActiveSteering", + "supportsSteeringByInterruptRestart", + "supportsQueuedMessages", + "terminalStatusQuality" + ], + "additionalProperties": true + }, + "streaming": { + "type": "object", + "properties": { + "streamsAssistantText": { + "type": "boolean" + }, + "streamsReasoning": { + "type": "boolean" + }, + "streamsToolOutput": { + "type": "boolean" + }, + "streamsPlanText": { + "type": "boolean" + }, + "emitsMessageCompleted": { + "type": "boolean" + } + }, + "required": [ + "streamsAssistantText", + "streamsReasoning", + "streamsToolOutput", + "streamsPlanText", + "emitsMessageCompleted" + ], + "additionalProperties": true + }, + "tools": { + "type": "object", + "properties": { + "exposesToolItemIds": { + "type": "boolean" + }, + "emitsToolStarted": { + "type": "boolean" + }, + "emitsToolCompleted": { + "type": "boolean" + }, + "emitsToolOutput": { + "type": "boolean" + }, + "supportsMcpTools": { + "type": "boolean" + }, + "supportsDynamicToolCallbacks": { + "type": "boolean" + } + }, + "required": [ + "exposesToolItemIds", + "emitsToolStarted", + "emitsToolCompleted", + "emitsToolOutput", + "supportsMcpTools", + "supportsDynamicToolCallbacks" + ], + "additionalProperties": true + }, + "approvals": { + "type": "object", + "properties": { + "supportsCommandApproval": { + "type": "boolean" + }, + "supportsFileReadApproval": { + "type": "boolean" + }, + "supportsFileChangeApproval": { + "type": "boolean" + }, + "supportsApplyPatchApproval": { + "type": "boolean" + }, + "approvalsHaveNativeRequestIds": { + "type": "boolean" + }, + "approvalCallbacksAreLiveOnly": { + "type": "boolean" + }, + "approvalsCanOriginateFromSubagents": { + "type": "boolean" + } + }, + "required": [ + "supportsCommandApproval", + "supportsFileReadApproval", + "supportsFileChangeApproval", + "supportsApplyPatchApproval", + "approvalsHaveNativeRequestIds", + "approvalCallbacksAreLiveOnly", + "approvalsCanOriginateFromSubagents" + ], + "additionalProperties": true + }, + "planning": { + "type": "object", + "properties": { + "emitsPlanUpdated": { + "type": "boolean" + }, + "emitsTodoList": { + "type": "boolean" + }, + "emitsProposedPlan": { + "type": "boolean" + }, + "supportsStructuredQuestions": { + "type": "boolean" + }, + "planDeltasHaveItemIds": { + "type": "boolean" + } + }, + "required": [ + "emitsPlanUpdated", + "emitsTodoList", + "emitsProposedPlan", + "supportsStructuredQuestions", + "planDeltasHaveItemIds" + ], + "additionalProperties": true + }, + "subagents": { + "type": "object", + "properties": { + "supportsSubagents": { + "type": "boolean" + }, + "exposesSubagentThreadIds": { + "type": "boolean" + }, + "emitsSubagentLifecycle": { + "type": "boolean" + }, + "canWaitForSubagents": { + "type": "boolean" + }, + "canCloseSubagents": { + "type": "boolean" + }, + "canForkSubagentThread": { + "type": "boolean" + } + }, + "required": [ + "supportsSubagents", + "exposesSubagentThreadIds", + "emitsSubagentLifecycle", + "canWaitForSubagents", + "canCloseSubagents", + "canForkSubagentThread" + ], + "additionalProperties": true + }, + "context": { + "type": "object", + "properties": { + "acceptsSystemContext": { + "type": "boolean" + }, + "acceptsDeveloperContext": { + "type": "boolean" + }, + "acceptsSyntheticUserContext": { + "type": "boolean" + }, + "canGenerateSummaries": { + "type": "boolean" + }, + "canConsumeHandoffSummaries": { + "type": "boolean" + }, + "supportsDeltaHandoff": { + "type": "boolean" + }, + "supportsFullThreadHandoff": { + "type": "boolean" + }, + "maxRecommendedHandoffChars": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "acceptsSystemContext", + "acceptsDeveloperContext", + "acceptsSyntheticUserContext", + "canGenerateSummaries", + "canConsumeHandoffSummaries", + "supportsDeltaHandoff", + "supportsFullThreadHandoff", + "maxRecommendedHandoffChars" + ], + "additionalProperties": true + }, + "checkpointing": { + "type": "object", + "properties": { + "appCanCheckpointFilesystem": { + "type": "boolean" + }, + "supportsNestedCheckpointScopes": { + "type": "boolean" + }, + "providerCanRollbackConversation": { + "type": "boolean" + }, + "providerRollbackReturnsSnapshot": { + "type": "boolean" + }, + "providerCanReadConversationSnapshot": { + "type": "boolean" + } + }, + "required": [ + "appCanCheckpointFilesystem", + "supportsNestedCheckpointScopes", + "providerCanRollbackConversation", + "providerRollbackReturnsSnapshot", + "providerCanReadConversationSnapshot" + ], + "additionalProperties": true + }, + "identity": { + "type": "object", + "properties": { + "nativeThreadIds": { + "type": "string", + "enum": [ + "strong", + "weak", + "none" + ] + }, + "nativeTurnIds": { + "type": "string", + "enum": [ + "strong", + "weak", + "none" + ] + }, + "nativeItemIds": { + "type": "string", + "enum": [ + "strong", + "weak", + "none" + ] + }, + "nativeRequestIds": { + "type": "string", + "enum": [ + "strong", + "weak", + "none" + ] + } + }, + "required": [ + "nativeThreadIds", + "nativeTurnIds", + "nativeItemIds", + "nativeRequestIds" + ], + "additionalProperties": true + }, + "runtimePolicy": { + "anyOf": [ + { + "type": "object", + "properties": { + "enforcement": { + "type": "string", + "enum": [ + "native", + "client-boundary" + ] + } + }, + "required": [ + "enforcement" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "sessions", + "threads", + "turns", + "streaming", + "tools", + "approvals", + "planning", + "subagents", + "context", + "checkpointing", + "identity" + ], + "additionalProperties": true + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "lastError": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "driver", + "providerInstanceId", + "status", + "cwd", + "model", + "capabilities", + "createdAt", + "updatedAt", + "lastError" + ], + "additionalProperties": true + } + }, + "providerThreads": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "driver": { + "type": "string" + }, + "providerInstanceId": { + "type": "string" + }, + "providerSessionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "appThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ownerNodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeThreadRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "nativeConversationHeadRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "not_loaded", + "idle", + "active", + "archived", + "closed", + "error" + ] + }, + "firstRunOrdinal": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "lastRunOrdinal": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "handoffIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "forkedFrom": { + "anyOf": [ + { + "type": "object", + "properties": { + "providerThreadId": { + "type": "string" + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "checkpointId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "providerThreadId" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "pendingBackgroundTasks": { + "anyOf": [ + { + "$ref": "#/$defs/PendingBackgroundTasks" + }, + { + "type": "null" + } + ] + }, + "contextUsage": { + "anyOf": [ + { + "type": "object", + "properties": { + "usedTokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "totalProcessedTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "maxTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "inputTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "cachedInputTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "outputTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "reasoningOutputTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "lastUsedTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "lastInputTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "lastCachedInputTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "lastOutputTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "lastReasoningOutputTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "toolUses": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "durationMs": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "compactsAutomatically": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "autoCompactThreshold": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "cost": { + "anyOf": [ + { + "type": "object", + "properties": { + "amount": { + "type": "number" + }, + "currency": { + "type": "string" + } + }, + "required": [ + "amount", + "currency" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "usedTokens" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "nativeMetadata": { + "anyOf": [ + { + "$ref": "#/$defs/NativeMetadata" + }, + { + "type": "null" + } + ] + }, + "goal": { + "anyOf": [ + { + "$ref": "#/$defs/Goal" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "driver", + "providerInstanceId", + "providerSessionId", + "appThreadId", + "ownerNodeId", + "nativeThreadRef", + "nativeConversationHeadRef", + "status", + "firstRunOrdinal", + "lastRunOrdinal", + "handoffIds", + "forkedFrom", + "createdAt", + "updatedAt" + ], + "additionalProperties": true + } + }, + "providerTurns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "providerThreadId": { + "type": "string" + }, + "nodeId": { + "type": "string" + }, + "runAttemptId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeTurnRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "ordinal": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "status": { + "type": "string", + "enum": [ + "pending", + "running", + "completed", + "interrupted", + "failed", + "cancelled" + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "completedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "tokenUsage": { + "anyOf": [ + { + "$ref": "#/$defs/SourceTokenUsage" + }, + { + "type": "null" + } + ] + }, + "turnTokenUsage": { + "anyOf": [ + { + "type": "object", + "properties": { + "usageScope": { + "type": "string", + "enum": [ + "main_agent" + ] + }, + "cachedInputTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "cacheCreationTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "reasoningTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "hasSubagents": { + "type": "boolean" + }, + "models": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "localCommand": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "otherUsage": { + "anyOf": [ + { + "$ref": "#/$defs/OtherUsage" + }, + { + "type": "null" + } + ] + }, + "usageStatus": { + "type": "string", + "enum": [ + "complete" + ] + }, + "inputTokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "outputTokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "usageScope", + "hasSubagents", + "usageStatus", + "inputTokens", + "outputTokens" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "usageScope": { + "type": "string", + "enum": [ + "main_agent" + ] + }, + "cachedInputTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "cacheCreationTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "reasoningTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "hasSubagents": { + "type": "boolean" + }, + "models": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "localCommand": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "otherUsage": { + "anyOf": [ + { + "$ref": "#/$defs/OtherUsage" + }, + { + "type": "null" + } + ] + }, + "usageStatus": { + "type": "string", + "enum": [ + "partial", + "unavailable" + ] + }, + "inputTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "outputTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "usageScope", + "hasSubagents", + "usageStatus" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "providerThreadId", + "nodeId", + "runAttemptId", + "nativeTurnRef", + "ordinal", + "status", + "startedAt", + "completedAt" + ], + "additionalProperties": true + } + }, + "runtimeRequests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "nodeId": { + "type": "string" + }, + "providerTurnId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nativeRequestRef": { + "anyOf": [ + { + "$ref": "#/$defs/NativeItemRef" + }, + { + "type": "null" + } + ] + }, + "kind": { + "type": "string", + "enum": [ + "command", + "file-read", + "file-change", + "mcp-elicitation", + "permission", + "dynamic_tool_call", + "user_input", + "auth_refresh" + ] + }, + "status": { + "type": "string", + "enum": [ + "pending", + "resolved", + "expired", + "cancelled" + ] + }, + "responseCapability": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "live" + ] + }, + "providerSessionId": { + "type": "string" + }, + "supportsFeedback": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "providerSessionId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "message" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "not_resumable" + ] + }, + "reason": { + "type": "string" + } + }, + "required": [ + "type", + "reason" + ], + "additionalProperties": true + } + ] + }, + "createdAt": { + "type": "string" + }, + "resolvedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "decision": { + "anyOf": [ + { + "type": "string", + "enum": [ + "accept", + "acceptForSession", + "acceptAlways", + "decline", + "cancel" + ] + }, + { + "type": "null" + } + ] + }, + "feedback": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "answers": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "nodeId", + "providerTurnId", + "nativeRequestRef", + "kind", + "status", + "responseCapability", + "createdAt", + "resolvedAt" + ], + "additionalProperties": true + } + }, + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "notification": { + "anyOf": [ + { + "$ref": "#/$defs/Notification" + }, + { + "type": "null" + } + ] + }, + "createdBy": { + "type": "string", + "enum": [ + "user", + "agent", + "system" + ] + }, + "creationSource": { + "type": "string", + "enum": [ + "web", + "mobile", + "mcp", + "provider", + "server" + ] + }, + "scheduledTaskId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "senderThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "idleCompaction": { + "anyOf": [ + { + "type": "string", + "enum": [ + "beforeCacheExpires", + "onReturn" + ] + }, + { + "type": "null" + } + ] + }, + "workflowScript": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "role": { + "type": "string", + "enum": [ + "user", + "assistant", + "system" + ] + }, + "text": { + "type": "string" + }, + "context": { + "anyOf": [ + { + "$ref": "#/$defs/Context" + }, + { + "type": "null" + } + ] + }, + "attachments": { + "$ref": "#/$defs/Attachments" + }, + "streaming": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "delegatedCompletion": { + "anyOf": [ + { + "$ref": "#/$defs/DelegatedCompletion" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "createdBy", + "creationSource", + "id", + "threadId", + "runId", + "nodeId", + "role", + "text", + "attachments", + "streaming", + "createdAt", + "updatedAt" + ], + "additionalProperties": true + } + }, + "plans": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "active", + "completed", + "superseded" + ] + }, + "detailInTurnItem": { + "anyOf": [ + { + "type": "boolean", + "enum": [ + true + ] + }, + { + "type": "null" + } + ] + }, + "kind": { + "type": "string", + "enum": [ + "proposed_plan" + ] + }, + "markdown": { + "type": "string" + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "status", + "kind", + "markdown" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "draft", + "active", + "completed", + "superseded" + ] + }, + "detailInTurnItem": { + "anyOf": [ + { + "type": "boolean", + "enum": [ + true + ] + }, + { + "type": "null" + } + ] + }, + "kind": { + "type": "string", + "enum": [ + "todo_list" + ] + }, + "steps": { + "$ref": "#/$defs/Steps" + }, + "explanation": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "status", + "kind", + "steps" + ], + "additionalProperties": true + } + ] + } + }, + "turnItems": { + "type": "array", + "items": { + "$ref": "#/$defs/Item" + } + }, + "checkpointScopes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "type": "string" + }, + "parentScopeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "kind": { + "type": "string", + "enum": [ + "root_run", + "subagent", + "tool", + "provider_thread", + "manual" + ] + }, + "ordinalWithinParent": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "advancesAppRunCount": { + "type": "boolean" + }, + "cwd": { + "type": "string" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "id", + "threadId", + "runId", + "nodeId", + "parentScopeId", + "providerThreadId", + "kind", + "ordinalWithinParent", + "advancesAppRunCount", + "cwd", + "createdAt" + ], + "additionalProperties": true + } + }, + "checkpoints": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "scopeId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nodeId": { + "type": "string" + }, + "parentCheckpointId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ordinalWithinScope": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "appRunOrdinal": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "ref": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "ready", + "missing", + "error", + "stale" + ] + }, + "files": { + "$ref": "#/$defs/Files" + }, + "filesTotal": { + "$ref": "#/$defs/FilesTotal" + }, + "capturedAt": { + "type": "string" + } + }, + "required": [ + "id", + "threadId", + "scopeId", + "runId", + "nodeId", + "parentCheckpointId", + "ordinalWithinScope", + "appRunOrdinal", + "ref", + "status", + "files", + "capturedAt" + ], + "additionalProperties": true + } + }, + "contextHandoffs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "transferId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "threadId": { + "type": "string" + }, + "targetRunId": { + "type": "string" + }, + "fromProviderThreadIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "toProviderThreadId": { + "type": "string" + }, + "coveredRunOrdinals": { + "type": "object", + "properties": { + "from": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "to": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "from", + "to" + ], + "additionalProperties": true + }, + "strategy": { + "type": "string", + "enum": [ + "delta_since_target_last_seen", + "fork_delta_summary", + "full_thread_summary", + "checkpoint_summary", + "manual_context" + ] + }, + "status": { + "type": "string", + "enum": [ + "pending", + "ready", + "failed", + "superseded" + ] + }, + "summaryMessageId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "summaryText": { + "type": "string" + }, + "history": { + "anyOf": [ + { + "type": "object", + "properties": { + "messages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "role": { + "type": "string", + "enum": [ + "user", + "assistant" + ] + }, + "text": { + "type": "string" + }, + "runStatus": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "threadId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "itemId": { + "type": "string" + }, + "providerThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string" + }, + "kind": { + "type": "string" + } + }, + "required": [ + "role", + "text", + "threadId", + "runId", + "itemId", + "providerThreadId", + "status", + "kind" + ], + "additionalProperties": true + } + }, + "coverage": { + "type": "string" + }, + "omittedItems": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "omittedItemIds": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "messages", + "coverage", + "omittedItems" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "delivery": { + "anyOf": [ + { + "type": "object", + "properties": { + "nativeThreadId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "injected", + "inline" + ] + }, + "itemIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "omittedItemIds": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "nativeThreadId", + "status", + "itemIds" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "detailInTurnItem": { + "anyOf": [ + { + "type": "boolean", + "enum": [ + true + ] + }, + { + "type": "null" + } + ] + }, + "createdByProviderInstanceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "threadId", + "targetRunId", + "fromProviderThreadIds", + "toProviderThreadId", + "coveredRunOrdinals", + "strategy", + "status", + "summaryMessageId", + "summaryText", + "createdByProviderInstanceId", + "createdAt", + "updatedAt" + ], + "additionalProperties": true + } + }, + "contextTransfers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "fork", + "provider_handoff", + "merge_back", + "subagent_spawn", + "subagent_result" + ] + }, + "sourceThreadId": { + "type": "string" + }, + "targetThreadId": { + "type": "string" + }, + "sourcePoint": { + "$ref": "#/$defs/BasePoint" + }, + "basePoint": { + "anyOf": [ + { + "$ref": "#/$defs/BasePoint" + }, + { + "type": "null" + } + ] + }, + "sourceProviderInstanceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sourceTokenUsage": { + "anyOf": [ + { + "$ref": "#/$defs/SourceTokenUsage" + }, + { + "type": "null" + } + ] + }, + "targetProviderInstanceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "targetRunId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "pending", + "resolved_native", + "resolved_portable", + "failed", + "consumed", + "superseded" + ] + }, + "resolution": { + "anyOf": [ + { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "enum": [ + "native_fork" + ] + }, + "providerThreadRef": { + "$ref": "#/$defs/NativeItemRef" + } + }, + "required": [ + "strategy", + "providerThreadRef" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "enum": [ + "portable_context" + ] + }, + "contextHandoffId": { + "type": "string" + } + }, + "required": [ + "strategy", + "contextHandoffId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "enum": [ + "delta_context" + ] + }, + "contextHandoffId": { + "type": "string" + } + }, + "required": [ + "strategy", + "contextHandoffId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "enum": [ + "fork_delta_context" + ] + }, + "contextHandoffId": { + "type": "string" + } + }, + "required": [ + "strategy", + "contextHandoffId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "strategy": { + "type": "string", + "enum": [ + "checkpoint_context" + ] + }, + "contextHandoffId": { + "type": "string" + } + }, + "required": [ + "strategy", + "contextHandoffId" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "createdBy": { + "type": "string", + "enum": [ + "user", + "agent", + "system" + ] + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "consumedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "type", + "sourceThreadId", + "targetThreadId", + "sourcePoint", + "basePoint", + "sourceProviderInstanceId", + "targetProviderInstanceId", + "targetRunId", + "status", + "resolution", + "createdBy", + "error", + "createdAt", + "updatedAt", + "consumedAt" + ], + "additionalProperties": true + } + }, + "visibleTurnItems": { + "type": "array", + "items": { + "type": "object", + "properties": { + "position": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "visibility": { + "type": "string", + "enum": [ + "local", + "inherited", + "synthetic" + ] + }, + "sourceThreadId": { + "type": "string" + }, + "sourceItemId": { + "type": "string" + }, + "item": { + "$ref": "#/$defs/Item" + } + }, + "required": [ + "position", + "visibility", + "sourceThreadId", + "sourceItemId", + "item" + ], + "additionalProperties": true + } + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "thread", + "runs", + "attempts", + "nodes", + "subagents", + "providerSessions", + "providerThreads", + "providerTurns", + "runtimeRequests", + "messages", + "plans", + "turnItems", + "checkpointScopes", + "checkpoints", + "contextHandoffs", + "contextTransfers", + "visibleTurnItems", + "updatedAt" + ], + "additionalProperties": true + }, + "OrchestrationGetTurnDiffError": { + "anyOf": [ + { + "$ref": "#/$defs/OrchestrationGetTurnDiffErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "OrchestrationGetTurnDiffErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "OrchestrationGetTurnDiffError" + ] + }, + "message": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": true + }, + "OrchestrationGetTurnItemPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "revision": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "threadId", + "itemId" + ], + "additionalProperties": true + }, + "OrchestrationGetTurnItemSuccess": { + "type": "object", + "properties": { + "item": { + "anyOf": [ + { + "$ref": "#/$defs/Item2" + }, + { + "$ref": "#/$defs/Item3" + }, + { + "$ref": "#/$defs/Item4" + }, + { + "$ref": "#/$defs/Item5" + }, + { + "$ref": "#/$defs/Item6" + }, + { + "$ref": "#/$defs/Item7" + }, + { + "$ref": "#/$defs/Item8" + }, + { + "$ref": "#/$defs/Item9" + }, + { + "$ref": "#/$defs/Item10" + }, + { + "$ref": "#/$defs/Item11" + }, + { + "$ref": "#/$defs/Item12" + }, + { + "$ref": "#/$defs/Item13" + }, + { + "$ref": "#/$defs/Item14" + }, + { + "$ref": "#/$defs/Item15" + }, + { + "$ref": "#/$defs/Item16" + }, + { + "$ref": "#/$defs/Item17" + }, + { + "$ref": "#/$defs/Item18" + }, + { + "$ref": "#/$defs/Item19" + }, + { + "$ref": "#/$defs/Item20" + }, + { + "$ref": "#/$defs/Item21" + }, + { + "$ref": "#/$defs/Item22" + }, + { + "$ref": "#/$defs/Item23" + }, + { + "$ref": "#/$defs/Item24" + }, + { + "$ref": "#/$defs/Item25" + }, + { + "$ref": "#/$defs/Item26" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "item" + ], + "additionalProperties": true + }, + "OrchestrationGetWorkflowScriptError": { + "anyOf": [ + { + "$ref": "#/$defs/OrchestrationGetWorkflowScriptErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "OrchestrationGetWorkflowScriptErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "OrchestrationGetWorkflowScriptError" + ] + }, + "reason": { + "type": "string", + "enum": [ + "invalid-path", + "root-unavailable", + "not-found", + "outside-root", + "not-js", + "not-regular-file", + "changed-during-read", + "read-failed" + ] + }, + "scriptPath": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "reason", + "scriptPath" + ], + "additionalProperties": true + }, + "OrchestrationGetWorkflowScriptPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "scriptPath": { + "type": "string" + } + }, + "required": [ + "threadId", + "scriptPath" + ], + "additionalProperties": true + }, + "OrchestrationGetWorkflowScriptSuccess": { + "type": "object", + "properties": { + "scriptPath": { + "type": "string" + }, + "contents": { + "type": "string" + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "scriptPath", + "contents", + "truncated" + ], + "additionalProperties": true + }, + "OrchestrationGetWorktreeThreadIdsPayload": { + "type": "object", + "properties": { + "worktreePath": { + "type": "string" + } + }, + "required": [ + "worktreePath" + ], + "additionalProperties": true + }, + "OrchestrationGetWorktreeThreadIdsSuccess": { + "type": "object", + "properties": { + "threadIds": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "threadIds" + ], + "additionalProperties": true + }, + "OrchestrationLaunchThreadError": { + "anyOf": [ + { + "$ref": "#/$defs/OrchestrationV2ThreadLaunchErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "OrchestrationLaunchThreadPayload": { + "type": "object", + "properties": { + "commandId": { + "type": "string" + }, + "creationSource": { + "anyOf": [ + { + "type": "string", + "enum": [ + "web", + "mobile", + "mcp", + "provider", + "server" + ] + }, + { + "type": "null" + } + ] + }, + "threadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reuseExistingThread": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "projectId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "generateTitle": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "modelSelection": { + "$ref": "#/$defs/ModelSelection" + }, + "runtimeMode": { + "type": "string", + "enum": [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + "interactionMode": { + "type": "string", + "enum": [ + "default", + "plan" + ] + }, + "workspaceStrategy": { + "$ref": "#/$defs/WorkspaceStrategy3" + }, + "initialMessage": { + "anyOf": [ + { + "type": "object", + "properties": { + "messageId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "text": { + "type": "string" + }, + "context": { + "anyOf": [ + { + "$ref": "#/$defs/Context" + }, + { + "type": "null" + } + ] + }, + "attachments": { + "$ref": "#/$defs/Attachments" + } + }, + "required": [ + "text", + "attachments" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "commandId", + "projectId", + "title", + "modelSelection", + "runtimeMode", + "interactionMode", + "workspaceStrategy" + ], + "additionalProperties": true + }, + "OrchestrationLaunchThreadSuccess": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "projection": { + "$ref": "#/$defs/OrchestrationGetThreadProjectionSuccess" + }, + "resumed": { + "type": "boolean" + }, + "sequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "threadId", + "projection", + "resumed" + ], + "additionalProperties": true + }, + "OrchestrationSearchThreadError": { + "anyOf": [ + { + "$ref": "#/$defs/OrchestrationV2SearchThreadErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "OrchestrationSearchThreadPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "skills": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string", + "maxLength": 200 + }, + "displayName": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name" + ], + "additionalProperties": true + }, + "maxItems": 1000 + }, + "query": { + "type": "string" + }, + "index": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "offset": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "start": { + "type": "object", + "properties": { + "entryId": { + "type": "string" + }, + "occurrence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "entryId", + "occurrence" + ], + "additionalProperties": true + } + }, + "required": [ + "threadId", + "query" + ], + "additionalProperties": true + }, + "OrchestrationSearchThreadSuccess": { + "type": "object", + "properties": { + "complete": { + "type": "boolean" + }, + "snapshotSequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "totalMatches": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "activeIndex": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "match": { + "anyOf": [ + { + "type": "object", + "properties": { + "entryId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "occurrence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "entryId", + "runId", + "occurrence" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "navigation": { + "type": "array", + "items": { + "type": "object", + "properties": { + "entryId": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startIndex": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "entryId", + "runId", + "startIndex", + "count" + ], + "additionalProperties": true + }, + "maxItems": 17 + } + }, + "required": [ + "snapshotSequence", + "totalMatches", + "activeIndex", + "match" + ], + "additionalProperties": true + }, + "OrchestrationSearchThreadsError": { + "anyOf": [ + { + "$ref": "#/$defs/OrchestrationSearchThreadsErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "OrchestrationSearchThreadsErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "OrchestrationSearchThreadsError" + ] + }, + "message": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": true + }, + "OrchestrationSearchThreadsPayload": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 50 + }, + "olderSettledBefore": { + "type": "string" + }, + "projectIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "environmentLabel": { + "type": "string" + }, + "titlesOnly": { + "type": "boolean" + } + }, + "required": [ + "query" + ], + "additionalProperties": true + }, + "OrchestrationSearchThreadsSuccess": { + "type": "object", + "properties": { + "matches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "source": { + "type": "string", + "enum": [ + "user", + "assistant" + ] + }, + "snippet": { + "type": "string", + "maxLength": 240 + }, + "messageCreatedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "quality": { + "type": "number" + }, + "hits": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "messageId": { + "type": "string" + } + }, + "required": [ + "threadId", + "projectId", + "source", + "snippet", + "messageCreatedAt" + ], + "additionalProperties": true + } + }, + "snapshotSequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "threads": { + "$ref": "#/$defs/Threads" + } + }, + "required": [ + "matches" + ], + "additionalProperties": true + }, + "OrchestrationStopWorkflowError": { + "anyOf": [ + { + "$ref": "#/$defs/OrchestrationV2StopWorkflowErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "OrchestrationStopWorkflowPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "subagentId": { + "type": "string" + } + }, + "required": [ + "threadId", + "subagentId" + ], + "additionalProperties": true + }, + "OrchestrationSubscribeCommandOutputError": { + "anyOf": [ + { + "$ref": "#/$defs/OrchestrationV2CommandOutputErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "OrchestrationSubscribeCommandOutputPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "itemId": { + "type": "string" + } + }, + "required": [ + "threadId", + "itemId" + ], + "additionalProperties": true + }, + "OrchestrationSubscribeCommandOutputSuccess": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "replace", + "append" + ] + }, + "text": { + "type": "string" + }, + "truncated": { + "type": "boolean" + }, + "running": { + "type": "boolean" + } + }, + "required": [ + "kind", + "text", + "truncated", + "running" + ], + "additionalProperties": true + }, + "OrchestrationSubscribeShellPayload": { + "type": "object", + "properties": { + "afterSequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requestCompletionMarker": { + "type": "boolean" + }, + "omitIdleSubagents": { + "type": "boolean" + }, + "omitOlderSettled": { + "type": "boolean" + } + }, + "additionalProperties": true + }, + "OrchestrationSubscribeShellSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "synchronized" + ] + } + }, + "required": [ + "kind" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "snapshot" + ] + }, + "snapshot": { + "type": "object", + "properties": { + "schemaVersion": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "snapshotSequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "threads": { + "$ref": "#/$defs/Threads" + }, + "archivedThreads": { + "$ref": "#/$defs/Threads" + }, + "projects": { + "$ref": "#/$defs/Projects" + }, + "olderSettled": { + "type": "object", + "properties": { + "before": { + "type": "string" + }, + "asOfSequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "counts": { + "$ref": "#/$defs/Counts" + } + }, + "required": [ + "before", + "asOfSequence", + "counts" + ], + "additionalProperties": true + } + }, + "required": [ + "schemaVersion", + "snapshotSequence", + "threads", + "archivedThreads", + "projects" + ], + "additionalProperties": true + }, + "resolvedRepositoryIdentityRoots": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "kind", + "snapshot" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "project.updated" + ] + }, + "sequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "project": { + "$ref": "#/$defs/ProjectsEntry" + } + }, + "required": [ + "kind", + "sequence", + "project" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "project.removed" + ] + }, + "sequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "projectId": { + "type": "string" + } + }, + "required": [ + "kind", + "sequence", + "projectId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "thread.updated" + ] + }, + "sequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "location": { + "type": "string", + "enum": [ + "active", + "archive" + ] + }, + "thread": { + "$ref": "#/$defs/ThreadsEntry" + } + }, + "required": [ + "kind", + "sequence", + "location", + "thread" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "thread.removed" + ] + }, + "sequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "location": { + "type": "string", + "enum": [ + "active", + "archive" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "kind", + "sequence", + "location", + "threadId" + ], + "additionalProperties": true + } + ] + }, + "OrchestrationSubscribeThreadPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "afterSequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requestCompletionMarker": { + "type": "boolean" + }, + "acceptBoundedSnapshot": { + "type": "boolean" + }, + "acceptDeferredWork": { + "type": "boolean" + }, + "acceptCompactTurnItems": { + "type": "boolean" + }, + "acceptDedupedBodies": { + "type": "boolean" + }, + "acceptCompactIds": { + "type": "boolean" + } + }, + "required": [ + "threadId" + ], + "additionalProperties": true + }, + "OrchestrationSubscribeThreadSuccess": {}, + "OrchestrationTurnDiffRange": { + "type": "object", + "properties": { + "fromTurnCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "toTurnCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "threadId": { + "type": "string" + }, + "diff": { + "type": "string" + } + }, + "required": [ + "fromTurnCount", + "toTurnCount", + "threadId", + "diff" + ], + "additionalProperties": true + }, + "OrchestrationTurnDiffRange2": { + "type": "object", + "properties": { + "fromTurnCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "toTurnCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "threadId": { + "type": "string" + }, + "ignoreWhitespace": { + "type": "boolean" + } + }, + "required": [ + "fromTurnCount", + "toTurnCount", + "threadId" + ], + "additionalProperties": true + }, + "OrchestrationV2CommandOutputErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "OrchestrationV2CommandOutputError" + ] + }, + "threadId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "threadId", + "message" + ], + "additionalProperties": true + }, + "OrchestrationV2DispatchCommandErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "OrchestrationV2DispatchCommandError" + ] + }, + "commandId": { + "type": "string" + }, + "commandType": { + "type": "string" + }, + "message": { + "type": "string" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "commandId", + "commandType", + "message" + ], + "additionalProperties": true + }, + "OrchestrationV2ForceStopThreadErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "OrchestrationV2ForceStopThreadError" + ] + }, + "threadId": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "type": "string", + "enum": [ + "superseded" + ] + }, + { + "type": "null" + } + ] + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "threadId" + ], + "additionalProperties": true + }, + "OrchestrationV2GetNativeChildWorkErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "OrchestrationV2GetNativeChildWorkError" + ] + }, + "threadId": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "threadId" + ], + "additionalProperties": true + }, + "OrchestrationV2GetShellSnapshotErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "OrchestrationV2GetShellSnapshotError" + ] + }, + "message": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": true + }, + "OrchestrationV2GetThreadProjectionErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "OrchestrationV2GetThreadProjectionError" + ] + }, + "threadId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "threadId", + "message" + ], + "additionalProperties": true + }, + "OrchestrationV2SearchThreadErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "OrchestrationV2SearchThreadError" + ] + }, + "cause": {} + }, + "required": [ + "_tag", + "cause" + ], + "additionalProperties": true + }, + "OrchestrationV2StopWorkflowErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "OrchestrationV2StopWorkflowError" + ] + }, + "threadId": { + "type": "string" + }, + "subagentId": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": [ + "not-running", + "unsupported", + "unavailable", + "stop-failed" + ] + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "threadId", + "subagentId", + "reason" + ], + "additionalProperties": true + }, + "OrchestrationV2ThreadLaunchErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "OrchestrationV2ThreadLaunchError" + ] + }, + "commandId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "commandId", + "projectId", + "message" + ], + "additionalProperties": true + }, + "OtherUsage": { + "type": "object", + "properties": { + "inputTokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "outputTokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "inputTokens", + "outputTokens" + ], + "additionalProperties": true + }, + "Overrides": { + "type": "object", + "properties": { + "automaticGitFetchInterval": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "providerHealthRefreshInterval": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "hostPowerMonitorActiveInterval": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "hostPowerMonitorIdleInterval": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "pauseWhenHostLocked": { + "type": "boolean" + }, + "pauseWhenHostLowPower": { + "type": "boolean" + }, + "pauseWhenClientLowPower": { + "type": "boolean" + }, + "pauseWhenOnBattery": { + "type": "boolean" + } + }, + "additionalProperties": true + }, + "PairingLinksEntry": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "scopes": { + "$ref": "#/$defs/Scopes" + }, + "permissions": { + "$ref": "#/$defs/Permissions" + }, + "subject": { + "type": "string" + }, + "label": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "expiresAt": { + "type": "string" + } + }, + "required": [ + "id", + "scopes", + "subject", + "createdAt", + "expiresAt" + ], + "additionalProperties": true + }, + "PeerLinkErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PeerLinkError" + ] + }, + "reason": { + "type": "string", + "enum": [ + "unreachable", + "not_a_t3_environment", + "incompatible", + "self", + "pairing_rejected", + "expired", + "unknown_link", + "storage", + "disabled" + ] + }, + "message": { + "type": "string" + }, + "cause": {} + }, + "required": [ + "_tag", + "reason", + "message" + ], + "additionalProperties": true + }, + "PeerLinkRequestErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PeerLinkRequestError" + ] + }, + "reason": { + "type": "string", + "enum": [ + "load_failed", + "not_found", + "already_answered", + "record_failed" + ] + }, + "cause": {} + }, + "required": [ + "_tag", + "reason" + ], + "additionalProperties": true + }, + "PeerLinksAnswerRequestError": { + "anyOf": [ + { + "$ref": "#/$defs/PeerLinkRequestErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "PeerLinksAnswerRequestPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "turnItemId": { + "type": "string" + }, + "answer": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "link" + ] + }, + "environmentId": { + "type": "string" + }, + "label": { + "type": "string" + }, + "urls": { + "type": "array", + "prefixItems": [ + { + "type": "string" + } + ], + "minItems": 1, + "items": { + "type": "string" + } + }, + "access": { + "type": "string", + "enum": [ + "read-only", + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + "pairingCode": { + "type": "string" + } + }, + "required": [ + "type", + "urls", + "access", + "pairingCode" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "use-existing" + ] + }, + "environmentId": { + "type": "string" + } + }, + "required": [ + "type", + "environmentId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "decline" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": true + } + ] + } + }, + "required": [ + "threadId", + "turnItemId", + "answer" + ], + "additionalProperties": true + }, + "PeerLinksLinkError": { + "anyOf": [ + { + "$ref": "#/$defs/PeerLinkErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "PeerLinksLinkPayload": { + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "alternateUrls": { + "type": "array", + "items": { + "type": "string" + } + }, + "pairingCode": { + "type": "string" + }, + "access": { + "type": "string", + "enum": [ + "read-only", + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + "expectedEnvironmentId": { + "type": "string" + }, + "expectedLabel": { + "type": "string" + } + }, + "required": [ + "url", + "pairingCode", + "access" + ], + "additionalProperties": true + }, + "PeerLinksLinkSuccess": { + "type": "object", + "properties": { + "environmentId": { + "type": "string" + }, + "label": { + "type": "string" + }, + "urls": { + "type": "array", + "items": { + "type": "string" + } + }, + "access": { + "type": "string", + "enum": [ + "read-only", + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + "linkedAt": { + "type": "string" + }, + "expiresAt": { + "type": "string" + }, + "lastReachedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "lastError": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "environmentId", + "label", + "urls", + "access", + "linkedAt", + "expiresAt", + "lastReachedAt", + "lastError" + ], + "additionalProperties": true + }, + "PeerLinksListSuccess": { + "type": "object", + "properties": { + "links": { + "type": "array", + "items": { + "type": "object", + "properties": { + "environmentId": { + "type": "string" + }, + "label": { + "type": "string" + }, + "urls": { + "type": "array", + "items": { + "type": "string" + } + }, + "access": { + "type": "string", + "enum": [ + "read-only", + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + "linkedAt": { + "type": "string" + }, + "expiresAt": { + "type": "string" + }, + "lastReachedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "lastError": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "reachable", + "unreachable", + "expired" + ] + } + }, + "required": [ + "environmentId", + "label", + "urls", + "access", + "linkedAt", + "expiresAt", + "lastReachedAt", + "lastError", + "status" + ], + "additionalProperties": true + } + } + }, + "required": [ + "links" + ], + "additionalProperties": true + }, + "PeerLinksUnlinkPayload": { + "type": "object", + "properties": { + "environmentId": { + "type": "string" + } + }, + "required": [ + "environmentId" + ], + "additionalProperties": true + }, + "PeerLinksUnlinkSuccess": { + "type": "object", + "properties": { + "removed": { + "type": "boolean" + } + }, + "required": [ + "removed" + ], + "additionalProperties": true + }, + "PendingBackgroundTasks": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "parentTaskId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "awaited": { + "anyOf": [ + { + "type": "boolean", + "enum": [ + true + ] + }, + { + "type": "null" + } + ] + }, + "kind": { + "type": "string", + "enum": [ + "subagent" + ] + }, + "providerInstanceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "childThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "taskId", + "kind" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "parentTaskId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "awaited": { + "anyOf": [ + { + "type": "boolean", + "enum": [ + true + ] + }, + { + "type": "null" + } + ] + }, + "kind": { + "type": "string", + "enum": [ + "command" + ] + } + }, + "required": [ + "taskId", + "kind" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "parentTaskId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "awaited": { + "anyOf": [ + { + "type": "boolean", + "enum": [ + true + ] + }, + { + "type": "null" + } + ] + }, + "kind": { + "type": "string", + "enum": [ + "monitor" + ] + } + }, + "required": [ + "taskId", + "kind" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "parentTaskId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "awaited": { + "anyOf": [ + { + "type": "boolean", + "enum": [ + true + ] + }, + { + "type": "null" + } + ] + }, + "kind": { + "type": "string", + "enum": [ + "background_task" + ] + } + }, + "required": [ + "taskId", + "kind" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "taskId": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "parentTaskId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "awaited": { + "anyOf": [ + { + "type": "boolean", + "enum": [ + true + ] + }, + { + "type": "null" + } + ] + }, + "kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "taskId" + ], + "additionalProperties": true + } + ] + } + }, + "Permissions": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/PermissionsEntry" + }, + { + "type": "null" + } + ] + } + }, + "PermissionsEntry": { + "type": "string", + "enum": [ + "orchestration:read", + "orchestration:operate", + "settings:write", + "providers:manage", + "environment:maintain", + "preview:operate", + "diagnostics:read", + "terminal:read", + "terminal:operate", + "filesystem:read", + "filesystem:write", + "review:write", + "source-control:write", + "access:read", + "access:write", + "relay:read", + "relay:write" + ] + }, + "PersistChatAttachmentsErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PersistChatAttachmentsError" + ] + }, + "message": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": true + }, + "Platforms": { + "type": "array", + "items": { + "type": "object", + "properties": { + "platform": { + "type": "string", + "enum": [ + "ios", + "android" + ] + }, + "available": { + "type": "boolean" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "platform", + "available" + ], + "additionalProperties": true + } + }, + "Position": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "added" + ] + }, + "newLine": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "kind", + "newLine" + ], + "additionalProperties": true + }, + "Position2": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "deleted" + ] + }, + "oldLine": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "kind", + "oldLine" + ], + "additionalProperties": true + }, + "Position3": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "context" + ] + }, + "oldLine": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "newLine": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "side": { + "type": "string", + "enum": [ + "left", + "right" + ] + } + }, + "required": [ + "kind", + "oldLine", + "newLine", + "side" + ], + "additionalProperties": true + }, + "Pr": { + "type": "object", + "properties": { + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "baseRef": { + "type": "string" + }, + "headRef": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed", + "merged" + ] + }, + "isDraft": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "number", + "title", + "url", + "baseRef", + "headRef", + "state" + ], + "additionalProperties": true + }, + "PreviewAdjustError": { + "anyOf": [ + { + "$ref": "#/$defs/PreviewSessionLookupErrorEncoded" + }, + { + "$ref": "#/$defs/PreviewInvalidUrlErrorEncoded" + }, + { + "$ref": "#/$defs/PreviewControlRequiredErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "PreviewAdjustPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "tabId": { + "type": "string" + }, + "colorScheme": { + "anyOf": [ + { + "type": "string", + "enum": [ + "system", + "light", + "dark" + ] + }, + { + "type": "null" + } + ] + }, + "zoomFactor": { + "anyOf": [ + { + "type": "number", + "enum": [ + 0.25, + 0.33, + 0.5, + 0.67, + 0.75, + 0.8, + 0.9, + 1, + 1.1, + 1.25, + 1.5, + 1.75, + 2, + 2.5, + 3, + 4, + 5 + ] + }, + { + "type": "null" + } + ] + }, + "hardReload": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "clear": { + "anyOf": [ + { + "type": "string", + "enum": [ + "cookies", + "cache" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "threadId", + "tabId" + ], + "additionalProperties": true + }, + "PreviewAdjustSuccess": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "tabId": { + "type": "string" + }, + "navStatus": { + "$ref": "#/$defs/NavStatus" + }, + "canGoBack": { + "type": "boolean" + }, + "canGoForward": { + "type": "boolean" + }, + "viewport": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "fill" + ] + } + }, + "required": [ + "_tag" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/Viewport" + }, + { + "$ref": "#/$defs/Viewport2" + }, + { + "type": "null" + } + ] + }, + "colorScheme": { + "anyOf": [ + { + "type": "string", + "enum": [ + "system", + "light", + "dark" + ] + }, + { + "type": "null" + } + ] + }, + "zoomFactor": { + "anyOf": [ + { + "type": "number", + "enum": [ + 0.25, + 0.33, + 0.5, + 0.67, + 0.75, + 0.8, + 0.9, + 1, + 1.1, + 1.25, + 1.5, + 1.75, + 2, + 2.5, + 3, + 4, + 5 + ] + }, + { + "type": "null" + } + ] + }, + "profileId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "runtime": { + "anyOf": [ + { + "type": "string", + "enum": [ + "desktop", + "server" + ] + }, + { + "type": "null" + } + ] + }, + "headless": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "automationOwner": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reveal": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "revealRequest": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "force": { + "type": "boolean" + } + }, + "required": [ + "id", + "force" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "threadId", + "tabId", + "navStatus", + "canGoBack", + "canGoForward", + "updatedAt" + ], + "additionalProperties": true + }, + "PreviewClearProfileError": { + "anyOf": [ + { + "$ref": "#/$defs/PreviewClearProfileErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "PreviewClearProfileErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PreviewClearProfileError" + ] + }, + "profileId": { + "type": "string" + }, + "cause": {} + }, + "required": [ + "_tag", + "profileId", + "cause" + ], + "additionalProperties": true + }, + "PreviewClearProfilePayload": { + "type": "object", + "properties": { + "profileId": { + "type": "string" + } + }, + "required": [ + "profileId" + ], + "additionalProperties": true + }, + "PreviewClosePayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "tabId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "threadId" + ], + "additionalProperties": true + }, + "PreviewControlRequiredErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PreviewControlRequiredError" + ] + }, + "tabId": { + "type": "string" + } + }, + "required": [ + "_tag", + "tabId" + ], + "additionalProperties": true + }, + "PreviewInvalidUrlErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PreviewInvalidUrlError" + ] + }, + "inputLength": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "reason": { + "type": "string", + "enum": [ + "empty", + "parse", + "unsupported-protocol", + "unexpected" + ] + }, + "protocol": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cause": {} + }, + "required": [ + "_tag", + "inputLength", + "reason", + "cause" + ], + "additionalProperties": true + }, + "PreviewListSuccess": { + "type": "object", + "properties": { + "sessions": { + "type": "array", + "items": { + "$ref": "#/$defs/PreviewAdjustSuccess" + } + }, + "serverEpoch": { + "type": "string" + }, + "revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "sessions", + "serverEpoch", + "revision" + ], + "additionalProperties": true + }, + "PreviewOpenPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "viewport": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "fill" + ] + } + }, + "required": [ + "_tag" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/Viewport" + }, + { + "$ref": "#/$defs/Viewport2" + }, + { + "type": "null" + } + ] + }, + "profileId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "runtime": { + "anyOf": [ + { + "type": "string", + "enum": [ + "desktop", + "server" + ] + }, + { + "type": "null" + } + ] + }, + "reveal": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "threadId" + ], + "additionalProperties": true + }, + "PreviewReportProfilesPayload": { + "type": "object", + "properties": { + "profiles": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "persistent", + "incognito" + ] + } + }, + "required": [ + "id", + "name", + "kind" + ], + "additionalProperties": true + }, + "maxItems": 24 + }, + "defaultProfileId": { + "type": "string" + }, + "recordingFrameRate": { + "anyOf": [ + { + "type": "number", + "enum": [ + 30, + 60 + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "profiles", + "defaultProfileId" + ], + "additionalProperties": true + }, + "PreviewReportStatusPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "tabId": { + "type": "string" + }, + "navStatus": { + "$ref": "#/$defs/NavStatus" + }, + "canGoBack": { + "type": "boolean" + }, + "canGoForward": { + "type": "boolean" + } + }, + "required": [ + "threadId", + "tabId", + "navStatus", + "canGoBack", + "canGoForward" + ], + "additionalProperties": true + }, + "PreviewResizePayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "tabId": { + "type": "string" + }, + "viewport": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "fill" + ] + } + }, + "required": [ + "_tag" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/Viewport" + }, + { + "$ref": "#/$defs/Viewport2" + } + ] + } + }, + "required": [ + "threadId", + "tabId", + "viewport" + ], + "additionalProperties": true + }, + "PreviewSessionLookupErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PreviewSessionLookupError" + ] + }, + "threadId": { + "type": "string" + }, + "tabId": { + "type": "string" + } + }, + "required": [ + "_tag", + "threadId", + "tabId" + ], + "additionalProperties": true + }, + "PreviewStopLocalServerError": { + "anyOf": [ + { + "$ref": "#/$defs/PreviewStopLocalServerErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "PreviewStopLocalServerErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PreviewStopLocalServerError" + ] + }, + "reason": { + "type": "string", + "enum": [ + "not-found", + "shared-group", + "unsupported", + "signal-failed" + ] + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "reason" + ], + "additionalProperties": true + }, + "PreviewStopLocalServerPayload": { + "type": "object", + "properties": { + "pid": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "port": { + "type": "integer", + "exclusiveMinimum": 0, + "exclusiveMaximum": 65536 + } + }, + "required": [ + "pid", + "port" + ], + "additionalProperties": true + }, + "Pricing": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "fresh", + "cached", + "unavailable" + ] + }, + "source": { + "type": "string" + }, + "fetchedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "knownModels": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "status", + "source", + "fetchedAt", + "knownModels" + ], + "additionalProperties": true + }, + "Profile": { + "type": "object", + "properties": { + "registration": { + "$ref": "#/$defs/Registration" + }, + "credentials": { + "type": "object", + "properties": { + "clientId": { + "type": "string" + }, + "accessToken": { + "type": "string", + "minLength": 1, + "maxLength": 16384 + }, + "refreshToken": { + "anyOf": [ + { + "type": "string", + "maxLength": 16384 + }, + { + "type": "null" + } + ] + }, + "idToken": { + "type": "string", + "minLength": 1, + "maxLength": 16384 + }, + "issuer": { + "type": "string" + }, + "expiresAt": { + "type": "number" + }, + "earliestRefreshAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "scopes": { + "type": "array", + "items": { + "type": "string" + } + }, + "subject": { + "type": "string" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "clientId", + "accessToken", + "refreshToken", + "idToken", + "issuer", + "expiresAt", + "earliestRefreshAt", + "scopes", + "subject", + "email" + ], + "additionalProperties": true + } + }, + "required": [ + "registration", + "credentials" + ], + "additionalProperties": true + }, + "ProjectCloneCancelPayload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + } + }, + "required": [ + "projectId" + ], + "additionalProperties": true + }, + "ProjectCloneCancelSuccess": { + "type": "object", + "properties": { + "applied": { + "type": "boolean" + } + }, + "required": [ + "applied" + ], + "additionalProperties": true + }, + "ProjectCloneRetryError": { + "anyOf": [ + { + "$ref": "#/$defs/SourceControlRepositoryErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ProjectCloneStartError": { + "anyOf": [ + { + "$ref": "#/$defs/SourceControlRepositoryErrorEncoded" + }, + { + "$ref": "#/$defs/OrchestrationDispatchCommandErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ProjectCloneStartPayload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "repository": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "remoteUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "destinationPath": { + "type": "string" + }, + "protocol": { + "anyOf": [ + { + "type": "string", + "enum": [ + "auto", + "ssh", + "https" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "projectId", + "title", + "createdAt", + "destinationPath" + ], + "additionalProperties": true + }, + "ProjectCloneStartSuccess": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "remoteUrl": { + "type": "string" + }, + "repository": { + "anyOf": [ + { + "$ref": "#/$defs/Repository" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "projectId", + "cwd", + "remoteUrl", + "repository" + ], + "additionalProperties": true + }, + "ProjectIcon": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "lucide" + ] + }, + "name": { + "type": "string" + }, + "color": { + "type": "string", + "enum": [ + "gray", + "red", + "orange", + "amber", + "yellow", + "lime", + "green", + "emerald", + "teal", + "cyan", + "sky", + "blue", + "indigo", + "violet", + "purple", + "fuchsia", + "pink", + "rose" + ] + }, + "monogramText": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "monogram": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "name", + "color" + ], + "additionalProperties": true + }, + "ProjectIcon2": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "monogram" + ] + }, + "text": { + "type": "string" + }, + "color": { + "type": "string", + "enum": [ + "gray", + "red", + "orange", + "amber", + "yellow", + "lime", + "green", + "emerald", + "teal", + "cyan", + "sky", + "blue", + "indigo", + "violet", + "purple", + "fuchsia", + "pink", + "rose" + ] + } + }, + "required": [ + "kind", + "text", + "color" + ], + "additionalProperties": true + }, + "ProjectListEntriesErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProjectListEntriesError" + ] + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "failure": { + "anyOf": [ + { + "$ref": "#/$defs/Failure2" + }, + { + "type": "null" + } + ] + }, + "normalizedCwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "timeout": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": true + }, + "ProjectMutationErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProjectMutationError" + ] + }, + "commandId": { + "type": "string" + }, + "message": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "commandId", + "message" + ], + "additionalProperties": true + }, + "ProjectReadFileErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProjectReadFileError" + ] + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "relativePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "failure": { + "anyOf": [ + { + "type": "string", + "enum": [ + "workspace_path_outside_root", + "resolved_path_outside_root", + "path_not_file", + "binary_file", + "operation_failed" + ] + }, + { + "type": "null" + } + ] + }, + "resolvedPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "resolvedWorkspaceRoot": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "operation": { + "anyOf": [ + { + "type": "string", + "enum": [ + "realpath-workspace-root", + "realpath-target", + "open", + "stat", + "read", + "close", + "make-directory", + "write-file" + ] + }, + { + "type": "null" + } + ] + }, + "operationPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": true + }, + "ProjectScriptOverrides": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/DefaultProjectScripts" + }, + { + "type": "null" + } + ] + } + }, + "ProjectSearchContentsErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProjectSearchContentsError" + ] + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "queryLength": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "limit": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "failure": { + "anyOf": [ + { + "$ref": "#/$defs/Failure2" + }, + { + "type": "null" + } + ] + }, + "normalizedCwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "timeout": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": true + }, + "ProjectSearchEntriesErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProjectSearchEntriesError" + ] + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "queryLength": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "limit": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "failure": { + "anyOf": [ + { + "$ref": "#/$defs/Failure2" + }, + { + "type": "null" + } + ] + }, + "normalizedCwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "timeout": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": true + }, + "ProjectWriteFileErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProjectWriteFileError" + ] + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "relativePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "failure": { + "anyOf": [ + { + "type": "string", + "enum": [ + "workspace_path_outside_root", + "resolved_path_outside_root", + "path_not_file", + "binary_file", + "operation_failed" + ] + }, + { + "type": "null" + } + ] + }, + "resolvedPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "resolvedWorkspaceRoot": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "operation": { + "anyOf": [ + { + "type": "string", + "enum": [ + "realpath-workspace-root", + "realpath-target", + "open", + "stat", + "read", + "close", + "make-directory", + "write-file" + ] + }, + { + "type": "null" + } + ] + }, + "operationPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "message": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": true + }, + "Projects": { + "type": "array", + "items": { + "$ref": "#/$defs/ProjectsEntry" + } + }, + "ProjectsCreateFromThreadError": { + "anyOf": [ + { + "$ref": "#/$defs/OrchestrationDispatchCommandErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ProjectsCreateNewPayload": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "additionalProperties": true + }, + "ProjectsCreateNewSuccess": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "workspaceRoot": { + "type": "string" + }, + "commitError": { + "type": "string" + } + }, + "required": [ + "projectId", + "workspaceRoot" + ], + "additionalProperties": true + }, + "ProjectsEntry": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "workspaceRoot": { + "type": "string" + }, + "repositoryIdentity": { + "anyOf": [ + { + "$ref": "#/$defs/RepositoryIdentity" + }, + { + "type": "null" + } + ] + }, + "defaultModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "defaultThreadEnvMode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "local", + "worktree" + ] + }, + { + "type": "null" + } + ] + }, + "autoPull": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "faviconPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "projectIcon": { + "anyOf": [ + { + "$ref": "#/$defs/ProjectIcon" + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "emoji" + ] + }, + "emoji": { + "type": "string" + } + }, + "required": [ + "kind", + "emoji" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/ProjectIcon2" + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "required": [ + "kind" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "scripts": { + "$ref": "#/$defs/DefaultProjectScripts" + }, + "pinnedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "archivedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "title", + "workspaceRoot", + "defaultModelSelection", + "scripts", + "createdAt", + "updatedAt" + ], + "additionalProperties": true + }, + "ProjectsListEntriesError": { + "anyOf": [ + { + "$ref": "#/$defs/ProjectListEntriesErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ProjectsListEntriesPayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "directoryPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "cwd" + ], + "additionalProperties": true + }, + "ProjectsListEntriesSuccess": { + "type": "object", + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "file", + "directory" + ] + }, + "ignored": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "path", + "kind" + ], + "additionalProperties": true + } + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "entries", + "truncated" + ], + "additionalProperties": true + }, + "ProjectsListIgnoredFilesPayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + } + }, + "required": [ + "cwd" + ], + "additionalProperties": true + }, + "ProjectsListIgnoredFilesSuccess": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "byteLength": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "path", + "byteLength" + ], + "additionalProperties": true + } + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "files", + "truncated" + ], + "additionalProperties": true + }, + "ProjectsMutateError": { + "anyOf": [ + { + "$ref": "#/$defs/ProjectMutationErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ProjectsMutatePayload": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "project.create" + ] + }, + "commandId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "workspaceRoot": { + "type": "string" + }, + "createWorkspaceRootIfMissing": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "defaultModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "scripts": { + "anyOf": [ + { + "$ref": "#/$defs/DefaultProjectScripts" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "projectId", + "title", + "workspaceRoot" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "project.update" + ] + }, + "commandId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspaceRoot": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "defaultModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "autoPull": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "projectIcon": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "lucide" + ] + }, + "name": { + "type": "string" + }, + "color": { + "type": "string", + "enum": [ + "gray", + "red", + "orange", + "amber", + "yellow", + "lime", + "green", + "emerald", + "teal", + "cyan", + "sky", + "blue", + "indigo", + "violet", + "purple", + "fuchsia", + "pink", + "rose" + ] + } + }, + "required": [ + "kind", + "name", + "color" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "emoji" + ] + }, + "emoji": { + "type": "string" + } + }, + "required": [ + "kind", + "emoji" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/ProjectIcon2" + }, + { + "type": "null" + } + ] + }, + "faviconPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "defaultThreadEnvMode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "local", + "worktree" + ] + }, + { + "type": "null" + } + ] + }, + "scripts": { + "anyOf": [ + { + "$ref": "#/$defs/DefaultProjectScripts" + }, + { + "type": "null" + } + ] + }, + "pinned": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "archived": { + "anyOf": [ + { + "type": "boolean", + "description": "Threads and files are kept." + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "projectId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "project.delete" + ] + }, + "commandId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "force": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "commandId", + "projectId" + ], + "additionalProperties": true + } + ] + }, + "ProjectsMutateSuccess": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "workspaceRoot": { + "type": "string" + }, + "repositoryIdentity": { + "anyOf": [ + { + "$ref": "#/$defs/RepositoryIdentity" + }, + { + "type": "null" + } + ] + }, + "faviconPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "projectIcon": { + "anyOf": [ + { + "$ref": "#/$defs/ProjectIcon" + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "emoji" + ] + }, + "emoji": { + "type": "string" + } + }, + "required": [ + "kind", + "emoji" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/ProjectIcon2" + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string" + } + }, + "required": [ + "kind" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "defaultModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "defaultThreadEnvMode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "local", + "worktree" + ] + }, + { + "type": "null" + } + ] + }, + "autoPull": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "scripts": { + "$ref": "#/$defs/DefaultProjectScripts" + }, + "pinnedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "archivedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "deletedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "title", + "workspaceRoot", + "defaultModelSelection", + "scripts", + "createdAt", + "updatedAt", + "deletedAt" + ], + "additionalProperties": true + }, + "ProjectsReadFileError": { + "anyOf": [ + { + "$ref": "#/$defs/ProjectReadFileErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ProjectsReadFilePayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "relativePath": { + "type": "string" + }, + "allowMissing": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "cwd", + "relativePath" + ], + "additionalProperties": true + }, + "ProjectsReadFileSuccess": { + "type": "object", + "properties": { + "relativePath": { + "type": "string" + }, + "contents": { + "type": "string" + }, + "byteLength": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "truncated": { + "type": "boolean" + }, + "exists": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "relativePath", + "contents", + "byteLength", + "truncated" + ], + "additionalProperties": true + }, + "ProjectsSearchContentsError": { + "anyOf": [ + { + "$ref": "#/$defs/ProjectSearchContentsErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ProjectsSearchContentsPayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "query": { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 500 + }, + "caseSensitive": { + "type": "boolean" + }, + "wholeWord": { + "type": "boolean" + }, + "useRegex": { + "type": "boolean" + } + }, + "required": [ + "cwd", + "query", + "limit", + "caseSensitive", + "wholeWord", + "useRegex" + ], + "additionalProperties": true + }, + "ProjectsSearchContentsSuccess": { + "type": "object", + "properties": { + "matches": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "lineNumber": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "lineContent": { + "type": "string" + }, + "matchRanges": { + "type": "array", + "items": { + "type": "object", + "properties": { + "start": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "end": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "start", + "end" + ], + "additionalProperties": true + } + } + }, + "required": [ + "path", + "lineNumber", + "lineContent", + "matchRanges" + ], + "additionalProperties": true + } + }, + "truncated": { + "type": "boolean" + }, + "regexFallbackError": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "matches", + "truncated" + ], + "additionalProperties": true + }, + "ProjectsSearchEntriesError": { + "anyOf": [ + { + "$ref": "#/$defs/ProjectSearchEntriesErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ProjectsSearchEntriesPayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "query": { + "type": "string" + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + "kind": { + "anyOf": [ + { + "type": "string", + "enum": [ + "file", + "directory" + ] + }, + { + "type": "null" + } + ] + }, + "imageOnly": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "cwd", + "query", + "limit" + ], + "additionalProperties": true + }, + "ProjectsWriteFileError": { + "anyOf": [ + { + "$ref": "#/$defs/ProjectWriteFileErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ProjectsWriteFilePayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "relativePath": { + "type": "string" + }, + "contents": { + "type": "string" + } + }, + "required": [ + "cwd", + "relativePath", + "contents" + ], + "additionalProperties": true + }, + "ProjectsWriteFileSuccess": { + "type": "object", + "properties": { + "relativePath": { + "type": "string" + } + }, + "required": [ + "relativePath" + ], + "additionalProperties": true + }, + "ProviderAuthCancelError": { + "anyOf": [ + { + "$ref": "#/$defs/ProviderSetupErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ProviderAuthCancelPayload": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "flowId": { + "type": "string" + } + }, + "required": [ + "instanceId", + "flowId" + ], + "additionalProperties": true + }, + "ProviderAuthCancelSuccess": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "phase": { + "type": "string", + "enum": [ + "idle", + "starting", + "waiting", + "verifying", + "succeeded", + "failed", + "cancelled" + ] + }, + "flowId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "authorizationUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "methods": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "accountEmail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "agent", + "terminal", + "credentials" + ] + } + }, + "required": [ + "id", + "name", + "description", + "type" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "interaction": {}, + "credentialOwner": {} + }, + "required": [ + "instanceId", + "phase", + "flowId", + "authorizationUrl", + "expiresAt", + "message" + ], + "additionalProperties": true + }, + "ProviderAuthCompletePayload": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "flowId": { + "type": "string" + }, + "callbackUrl": { + "type": "string" + } + }, + "required": [ + "instanceId", + "flowId", + "callbackUrl" + ], + "additionalProperties": true + }, + "ProviderAuthLogoutPayload": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + } + }, + "required": [ + "instanceId" + ], + "additionalProperties": true + }, + "ProviderAuthRespondPayload": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "flowId": { + "type": "string" + }, + "interactionId": { + "type": "string" + }, + "response": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "browser" + ] + }, + "action": { + "type": "string", + "enum": [ + "accept", + "decline" + ] + } + }, + "required": [ + "type", + "action" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "terminal" + ] + }, + "data": { + "type": "string", + "maxLength": 4096 + }, + "size": { + "type": "object", + "properties": { + "cols": { + "type": "integer", + "minimum": 1, + "maximum": 500 + }, + "rows": { + "type": "integer", + "minimum": 1, + "maximum": 200 + } + }, + "required": [ + "cols", + "rows" + ], + "additionalProperties": true + } + }, + "required": [ + "type", + "data" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "credentials" + ] + }, + "values": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 16384 + }, + "maxProperties": 16 + } + }, + "required": [ + "type", + "values" + ], + "additionalProperties": true + } + ] + } + }, + "required": [ + "instanceId", + "flowId", + "interactionId", + "response" + ], + "additionalProperties": true + }, + "ProviderAuthStartPayload": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "methodId": { + "type": "string" + }, + "returnUrl": { + "type": "string" + }, + "callbackMode": { + "type": "string", + "enum": [ + "server", + "client" + ] + } + }, + "required": [ + "instanceId" + ], + "additionalProperties": true + }, + "ProviderChatgptHandoffSubscribePayload": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "environmentId": { + "type": "string" + }, + "attemptId": { + "type": "string", + "maxLength": 128 + }, + "returnUrl": { + "type": "string", + "maxLength": 4096 + }, + "profile": { + "anyOf": [ + { + "$ref": "#/$defs/Registration" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "instanceId", + "environmentId", + "attemptId", + "returnUrl", + "profile" + ], + "additionalProperties": true + }, + "ProviderChatgptHandoffSubscribeSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "phase": { + "type": "string", + "enum": [ + "auth" + ] + }, + "state": { + "$ref": "#/$defs/ProviderAuthCancelSuccess" + } + }, + "required": [ + "phase", + "state" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "phase": { + "type": "string", + "enum": [ + "finished" + ] + }, + "profile": { + "$ref": "#/$defs/Profile" + } + }, + "required": [ + "phase", + "profile" + ], + "additionalProperties": true + } + ] + }, + "ProviderChatgptImportProfilePayload": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "profile": { + "$ref": "#/$defs/Profile" + } + }, + "required": [ + "instanceId", + "profile" + ], + "additionalProperties": true + }, + "ProviderChatgptReconnectProfilePayload": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "methodId": { + "type": "string" + } + }, + "required": [ + "instanceId", + "methodId" + ], + "additionalProperties": true + }, + "ProviderChatgptReconnectProfileSuccess": { + "anyOf": [ + { + "$ref": "#/$defs/Registration" + }, + { + "type": "null" + } + ] + }, + "ProviderConsumeResetCreditError": { + "anyOf": [ + { + "$ref": "#/$defs/ProviderSetupErrorEncoded" + }, + { + "$ref": "#/$defs/UsageLimitSourceErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ProviderConsumeResetCreditPayload": { + "anyOf": [ + { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + } + }, + "required": [ + "instanceId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "sourceId": { + "type": "string" + }, + "accountId": { + "type": "string" + }, + "creditId": { + "type": "string" + } + }, + "required": [ + "sourceId", + "accountId", + "creditId" + ], + "additionalProperties": true + } + ] + }, + "ProviderConsumeResetCreditSuccess": { + "type": "object", + "properties": { + "outcome": { + "type": "string", + "enum": [ + "reset", + "nothingToReset", + "noCredit", + "alreadyRedeemed" + ] + }, + "warning": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "outcome" + ], + "additionalProperties": true + }, + "ProviderInstallCancelPayload": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "operationId": { + "type": "string" + } + }, + "required": [ + "instanceId", + "operationId" + ], + "additionalProperties": true + }, + "ProviderInstallCancelSuccess": { + "type": "object", + "properties": { + "driver": { + "type": "string" + }, + "operationId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "phase": { + "type": "string", + "enum": [ + "idle", + "downloading", + "extracting", + "verifying", + "succeeded", + "failed", + "cancelled" + ] + }, + "downloadedBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "totalBytes": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "installedVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "executablePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "source": { + "anyOf": [ + { + "type": "string", + "enum": [ + "managed", + "local" + ] + }, + { + "type": "null" + } + ] + }, + "canRemove": { + "type": "boolean" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "driver", + "operationId", + "phase", + "downloadedBytes", + "totalBytes", + "version", + "installedVersion", + "canRemove", + "message" + ], + "additionalProperties": true + }, + "ProviderInstances": { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/ProviderInstances2" + } + }, + "ProviderInstances2": { + "type": "object", + "properties": { + "driver": { + "type": "string" + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "accentColor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "environment": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sensitive": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "valueRedacted": { + "type": "boolean" + }, + "secret": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "reference": { + "type": "string" + } + }, + "required": [ + "provider", + "reference" + ], + "additionalProperties": true + } + }, + "required": [ + "name" + ], + "additionalProperties": true + } + }, + "enabled": { + "type": "boolean" + }, + "config": {} + }, + "required": [ + "driver" + ], + "additionalProperties": true + }, + "ProviderModelPreferences": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "hiddenModels": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "modelOrder": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + } + }, + "ProviderSetupErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProviderSetupError" + ] + }, + "instanceId": { + "type": "string" + }, + "operation": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "instanceId", + "operation", + "detail" + ], + "additionalProperties": true + }, + "ProviderUploadFeedbackError": { + "anyOf": [ + { + "$ref": "#/$defs/ProviderUploadFeedbackErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ProviderUploadFeedbackErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ProviderUploadFeedbackError" + ] + }, + "threadId": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "threadId" + ], + "additionalProperties": true + }, + "ProviderUploadFeedbackPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "threadId" + ], + "additionalProperties": true + }, + "ProviderUploadFeedbackSuccess": { + "type": "object", + "properties": { + "feedbackId": { + "type": "string" + } + }, + "required": [ + "feedbackId" + ], + "additionalProperties": true + }, + "Providers": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "driver": { + "type": "string" + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "accentColor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "iconUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "badgeLabel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "continuation": { + "anyOf": [ + { + "type": "object", + "properties": { + "groupKey": { + "type": "string" + } + }, + "required": [ + "groupKey" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "showInteractionModeToggle": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "reportsContextWindow": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "supportedRuntimeModes": { + "anyOf": [ + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "enum": [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + { + "type": "null" + } + ] + } + }, + { + "type": "null" + } + ] + }, + "supportsConversationRollback": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "supportsTextGeneration": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "setup": { + "anyOf": [ + { + "type": "object", + "properties": { + "canAuthenticate": { + "type": "boolean" + }, + "canInstall": { + "type": "boolean" + }, + "documentationUrl": { + "type": "string" + } + }, + "required": [ + "canAuthenticate", + "canInstall" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "nativeSessions": { + "anyOf": [ + { + "type": "object", + "properties": { + "canList": { + "type": "boolean" + }, + "canLoad": { + "type": "boolean" + }, + "canResume": { + "type": "boolean" + }, + "canDelete": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "canList", + "canLoad", + "canResume" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "configurableProviders": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "runtimePaths": { + "type": "object", + "properties": { + "homePath": { + "type": "string" + }, + "shadowHomePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "homePath", + "shadowHomePath" + ], + "additionalProperties": true + }, + "enabled": { + "type": "boolean" + }, + "installed": { + "type": "boolean" + }, + "version": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "installedVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "ready", + "warning", + "error", + "disabled" + ] + }, + "auth": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "authenticated", + "unauthenticated", + "unknown" + ] + }, + "type": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "action": { + "anyOf": [ + { + "$ref": "#/$defs/Action" + }, + { + "type": "null" + } + ] + }, + "canLogout": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "subscriptionSharing": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "profileId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspaceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": true + }, + "checkedAt": { + "type": "string" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "availability": { + "anyOf": [ + { + "type": "string", + "enum": [ + "available", + "unavailable" + ] + }, + { + "type": "null" + } + ] + }, + "unavailableReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "models": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "name": { + "type": "string" + }, + "shortName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "subProvider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "aliases": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "badge": { + "anyOf": [ + { + "type": "string", + "enum": [ + "new" + ] + }, + { + "type": "null" + } + ] + }, + "isCustom": { + "type": "boolean" + }, + "isDefault": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "isLegacy": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "capabilities": { + "anyOf": [ + { + "type": "object", + "properties": { + "optionDescriptors": { + "anyOf": [ + { + "$ref": "#/$defs/OptionDescriptors" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "slug", + "name", + "isCustom", + "capabilities" + ], + "additionalProperties": true + } + }, + "updateRequiredModels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "slug": { + "type": "string" + }, + "name": { + "type": "string" + }, + "badge": { + "anyOf": [ + { + "type": "string", + "enum": [ + "new" + ] + }, + { + "type": "null" + } + ] + }, + "minVersion": { + "type": "string" + } + }, + "required": [ + "slug", + "name", + "minVersion" + ], + "additionalProperties": true + } + }, + "slashCommands": { + "anyOf": [ + { + "$ref": "#/$defs/SlashCommands" + }, + { + "type": "null" + } + ] + }, + "skills": { + "anyOf": [ + { + "$ref": "#/$defs/Skills2" + }, + { + "type": "null" + } + ] + }, + "workspaceSnapshots": { + "type": "array", + "items": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "checkedAt": { + "type": "string" + }, + "slashCommands": { + "$ref": "#/$defs/SlashCommands" + }, + "slashCommandsPending": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "skills": { + "$ref": "#/$defs/Skills2" + }, + "optionDescriptors": { + "anyOf": [ + { + "$ref": "#/$defs/OptionDescriptors" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "cwd", + "checkedAt", + "slashCommands", + "skills" + ], + "additionalProperties": true + } + }, + "usageLimits": { + "anyOf": [ + { + "$ref": "#/$defs/UsageLimits" + }, + { + "type": "null" + } + ] + }, + "versionAdvisory": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "unknown", + "current", + "behind_latest" + ] + }, + "currentVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "latestVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updateCommand": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "canUpdate": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "canInstallVersion": { + "type": "boolean" + }, + "checkedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status", + "currentVersion", + "latestVersion", + "updateCommand", + "checkedAt", + "message" + ], + "additionalProperties": true + }, + "compatibilityAdvisory": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "unknown", + "supported", + "graceful", + "unsupported", + "broken" + ] + }, + "latestVersionStatus": { + "type": "string", + "enum": [ + "unknown", + "supported", + "graceful", + "unsupported", + "broken" + ] + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "recommendedVersion": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "recommendedRange": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status", + "message", + "recommendedVersion", + "recommendedRange" + ], + "additionalProperties": true + }, + "updateState": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "idle", + "queued", + "running", + "succeeded", + "failed", + "unchanged" + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "finishedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "output": { + "anyOf": [ + { + "type": "string", + "maxLength": 10000 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status", + "startedAt", + "finishedAt", + "message", + "output" + ], + "additionalProperties": true + } + }, + "required": [ + "instanceId", + "driver", + "enabled", + "installed", + "version", + "status", + "auth", + "checkedAt", + "models" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "PullRequest": { + "type": "object", + "properties": { + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "baseBranch": { + "type": "string" + }, + "headBranch": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed", + "merged" + ] + } + }, + "required": [ + "number", + "title", + "url", + "baseBranch", + "headBranch", + "state" + ], + "additionalProperties": true + }, + "PullRequestOperationErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PullRequestOperationError" + ] + }, + "operation": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "reason": { + "anyOf": [ + { + "type": "string", + "enum": [ + "not-found" + ] + }, + { + "type": "null" + } + ] + }, + "refusal": { + "anyOf": [ + { + "type": "string", + "enum": [ + "merge-blocked", + "merge-conflict", + "already-merged" + ] + }, + { + "type": "null" + } + ] + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "operation", + "detail" + ], + "additionalProperties": true + }, + "PullRequestUnavailableErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "PullRequestUnavailableError" + ] + }, + "reason": { + "type": "string", + "enum": [ + "cli-missing", + "cli-unauthenticated", + "provider-unsupported" + ] + }, + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "requirement": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "reason" + ], + "additionalProperties": true + }, + "PullRequests": { + "type": "array", + "items": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "url": { + "type": "string" + }, + "repositoryWebUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "source": { + "type": "string", + "enum": [ + "manual", + "created", + "agent", + "stack", + "stack-dismissed" + ] + }, + "linkedAt": { + "type": "string" + }, + "snapshot": { + "anyOf": [ + { + "$ref": "#/$defs/Snapshot" + }, + { + "type": "null" + } + ] + }, + "stack": { + "anyOf": [ + { + "$ref": "#/$defs/Stack" + }, + { + "type": "null" + } + ] + }, + "watch": { + "anyOf": [ + { + "$ref": "#/$defs/EndedWatch" + }, + { + "type": "null" + } + ] + }, + "endedWatch": { + "anyOf": [ + { + "$ref": "#/$defs/EndedWatch" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "host", + "repository", + "number", + "url", + "source", + "linkedAt", + "snapshot", + "stack" + ], + "additionalProperties": true + } + }, + "PullRequestsActivityError": { + "anyOf": [ + { + "$ref": "#/$defs/PullRequestUnavailableErrorEncoded" + }, + { + "$ref": "#/$defs/PullRequestOperationErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "PullRequestsActivityPayload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expectedAccountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "allowStale": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "projectId", + "repository", + "number" + ], + "additionalProperties": true + }, + "PullRequestsActivitySuccess": { + "type": "object", + "properties": { + "author": { + "anyOf": [ + { + "$ref": "#/$defs/Author" + }, + { + "type": "null" + } + ] + }, + "reviewers": { + "anyOf": [ + { + "$ref": "#/$defs/Authors" + }, + { + "type": "null" + } + ] + }, + "comments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "issue-comment", + "review-comment", + "review" + ] + }, + "author": { + "anyOf": [ + { + "$ref": "#/$defs/Author" + }, + { + "type": "null" + } + ] + }, + "body": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "editedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reviewState": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reactions": { + "anyOf": [ + { + "$ref": "#/$defs/Reactions" + }, + { + "type": "null" + } + ] + }, + "canEdit": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "kind", + "author", + "body", + "createdAt", + "url", + "path", + "reviewState" + ], + "additionalProperties": true + } + }, + "commentCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "commentsTruncated": { + "type": "boolean" + }, + "reviewThreadsTruncated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "reviewThreads": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "path": { + "type": "string" + }, + "line": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "side": { + "type": "string", + "enum": [ + "left", + "right" + ] + }, + "isResolved": { + "type": "boolean" + }, + "isOutdated": { + "type": "boolean" + }, + "comments": { + "$ref": "#/$defs/Comments" + }, + "commentCount": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "nextCommentsCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "canResolve": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "path", + "line", + "side", + "isResolved", + "isOutdated", + "comments" + ], + "additionalProperties": true + } + }, + "commits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "oid": { + "type": "string" + }, + "messageHeadline": { + "type": "string" + }, + "committedDate": { + "type": "string" + }, + "additions": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "deletions": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "authors": { + "anyOf": [ + { + "$ref": "#/$defs/Authors" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "oid", + "messageHeadline", + "committedDate" + ], + "additionalProperties": true + } + }, + "reactions": { + "anyOf": [ + { + "$ref": "#/$defs/Reactions" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "comments", + "commentCount", + "commentsTruncated", + "reviewThreads", + "commits" + ], + "additionalProperties": true + }, + "PullRequestsChangeBreakdownSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "code": { + "$ref": "#/$defs/Code" + }, + "tests": { + "$ref": "#/$defs/Code" + }, + "mocks": { + "$ref": "#/$defs/Code" + }, + "generated": { + "$ref": "#/$defs/Code" + }, + "docs": { + "anyOf": [ + { + "$ref": "#/$defs/Code" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "code", + "tests", + "mocks", + "generated" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "PullRequestsChecksSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "observedAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed", + "merged" + ] + }, + "checks": { + "$ref": "#/$defs/Checks" + } + }, + "required": [ + "state", + "checks" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "PullRequestsCommentPayload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expectedAccountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "allowStale": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "body": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + } + }, + "required": [ + "projectId", + "repository", + "number", + "body" + ], + "additionalProperties": true + }, + "PullRequestsDetailSuccess": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "capabilities": { + "type": "object", + "properties": { + "diff": { + "type": "boolean" + }, + "comment": { + "type": "boolean" + }, + "actions": { + "$ref": "#/$defs/Actions" + }, + "mergeMethods": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + }, + { + "type": "null" + } + ] + } + }, + "deleteBranch": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "mergeBypass": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "updateMethods": { + "anyOf": [ + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "enum": [ + "merge", + "rebase" + ] + }, + { + "type": "null" + } + ] + } + }, + { + "type": "null" + } + ] + }, + "search": { + "type": "boolean" + }, + "reactions": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "viewedFiles": { + "anyOf": [ + { + "type": "string", + "enum": [ + "host", + "environment" + ] + }, + { + "type": "null" + } + ] + }, + "review": { + "type": "object", + "properties": { + "inlineComment": { + "type": "boolean" + }, + "rangeComment": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "reply": { + "type": "boolean" + }, + "resolve": { + "type": "boolean" + }, + "verdicts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "enum": [ + "comment", + "approve", + "request-changes" + ] + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "inlineComment", + "reply", + "resolve", + "verdicts" + ], + "additionalProperties": true + }, + "reviewers": { + "type": "object", + "properties": { + "request": { + "type": "boolean" + }, + "listCandidates": { + "type": "boolean" + } + }, + "required": [ + "request", + "listCandidates" + ], + "additionalProperties": true + }, + "edit": { + "anyOf": [ + { + "type": "object", + "properties": { + "changeRequest": { + "type": "boolean" + }, + "comment": { + "type": "boolean" + }, + "maintainerEdits": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "changeRequest", + "comment" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "stacks": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "stackActions": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "labels": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "checksAlwaysReport": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "diff", + "comment", + "actions", + "mergeMethods", + "search", + "review", + "reviewers" + ], + "additionalProperties": true + }, + "viewerPermissions": { + "type": "object", + "properties": { + "stackRebase": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "actions": { + "$ref": "#/$defs/Actions" + }, + "comment": { + "type": "boolean" + }, + "resolve": { + "type": "boolean" + }, + "verdicts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "enum": [ + "comment", + "approve", + "request-changes" + ] + }, + { + "type": "null" + } + ] + } + }, + "requestReviewers": { + "type": "boolean" + }, + "updateMethods": { + "anyOf": [ + { + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "enum": [ + "merge", + "rebase" + ] + }, + { + "type": "null" + } + ] + } + }, + { + "type": "null" + } + ] + }, + "mergeBypass": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "labels": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "editChangeRequest": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "maintainerEdits": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "actions", + "comment", + "resolve", + "verdicts", + "requestReviewers" + ], + "additionalProperties": true + }, + "projectId": { + "type": "string" + }, + "projectTitle": { + "type": "string" + }, + "workspaceRoot": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + }, + "url": { + "type": "string" + }, + "author": { + "anyOf": [ + { + "$ref": "#/$defs/Author" + }, + { + "type": "null" + } + ] + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed", + "merged" + ] + }, + "isDraft": { + "type": "boolean" + }, + "mergeability": { + "type": "string", + "enum": [ + "mergeable", + "conflicting", + "unknown" + ] + }, + "additions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "deletions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "changedFiles": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "headBranch": { + "type": "string" + }, + "headRepositoryNameWithOwner": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "headSha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "baseBranch": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "observedAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "mergedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "closedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reviewers": { + "$ref": "#/$defs/Authors" + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "color" + ], + "additionalProperties": true + } + }, + "checks": { + "$ref": "#/$defs/Checks" + }, + "mergeCapabilities": { + "type": "object", + "properties": { + "merge": { + "type": "boolean" + }, + "squash": { + "type": "boolean" + }, + "rebase": { + "type": "boolean" + } + }, + "required": [ + "merge", + "squash", + "rebase" + ], + "additionalProperties": true + }, + "viewer": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "baseComparison": { + "anyOf": [ + { + "type": "string", + "enum": [ + "up-to-date", + "behind", + "unknown" + ] + }, + { + "type": "null" + } + ] + }, + "behindBy": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "autoMergeEnabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "autoMergeMethod": { + "anyOf": [ + { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + }, + { + "type": "null" + } + ] + }, + "workflowApprovalsRequired": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "maintainerCanModify": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "provider", + "capabilities", + "viewerPermissions", + "projectId", + "projectTitle", + "workspaceRoot", + "repository", + "number", + "title", + "body", + "url", + "author", + "state", + "isDraft", + "mergeability", + "additions", + "deletions", + "changedFiles", + "headBranch", + "baseBranch", + "createdAt", + "updatedAt", + "mergedAt", + "closedAt", + "reviewers", + "labels", + "checks", + "mergeCapabilities" + ], + "additionalProperties": true + }, + "PullRequestsDiffFileContentsPayload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expectedAccountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "allowStale": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "commit": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "changeType": { + "type": "string", + "enum": [ + "change", + "rename-pure", + "rename-changed", + "new", + "deleted" + ] + }, + "oldPath": { + "type": "string" + }, + "newPath": { + "type": "string" + } + }, + "required": [ + "projectId", + "repository", + "number", + "changeType", + "oldPath", + "newPath" + ], + "additionalProperties": true + }, + "PullRequestsDiffFileContentsSuccess": { + "type": "object", + "properties": { + "oldContents": { + "type": "string" + }, + "newContents": { + "type": "string" + } + }, + "required": [ + "oldContents", + "newContents" + ], + "additionalProperties": true + }, + "PullRequestsFilesViewedSuccess": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "state": { + "type": "string", + "enum": [ + "unviewed", + "viewed", + "dismissed" + ] + } + }, + "required": [ + "path", + "state" + ], + "additionalProperties": true + } + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "files", + "truncated" + ], + "additionalProperties": true + }, + "PullRequestsFilterOptionsPayload": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "labels", + "authors" + ] + }, + "projectId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "projectIds": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 100 + }, + { + "type": "null" + } + ] + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "query": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind" + ], + "additionalProperties": true + }, + "PullRequestsFilterOptionsSuccess": { + "type": "object", + "properties": { + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "color" + ], + "additionalProperties": true + } + }, + "authors": { + "$ref": "#/$defs/Authors" + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "labels", + "authors", + "truncated" + ], + "additionalProperties": true + }, + "PullRequestsGenerateReviewGuideSuccess": { + "type": "object", + "properties": { + "guide": { + "anyOf": [ + { + "type": "object", + "properties": { + "headSha": { + "type": "string" + }, + "generatedAt": { + "type": "string" + }, + "modelSelection": { + "$ref": "#/$defs/ModelSelection" + }, + "stops": { + "type": "array", + "items": { + "type": "object", + "properties": { + "title": { + "type": "string" + }, + "summary": { + "type": "string" + }, + "attention": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "tradeoff", + "contract", + "product", + "risk", + "accessibility" + ] + }, + "note": { + "type": "string" + } + }, + "required": [ + "kind", + "note" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "excerpts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "side": { + "type": "string", + "enum": [ + "new", + "old" + ] + }, + "start": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "end": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "caption": { + "type": "string" + } + }, + "required": [ + "path", + "side", + "start", + "end", + "caption" + ], + "additionalProperties": true + } + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "note": { + "type": "string" + } + }, + "required": [ + "path" + ], + "additionalProperties": true + } + }, + "notes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "line": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "title": { + "type": "string" + }, + "body": { + "type": "string" + } + }, + "required": [ + "path", + "line", + "title", + "body" + ], + "additionalProperties": true + } + }, + "questions": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "title", + "summary", + "attention", + "excerpts", + "files", + "notes", + "questions" + ], + "additionalProperties": true + } + } + }, + "required": [ + "headSha", + "generatedAt", + "modelSelection", + "stops" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "run": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Running" + ] + }, + "headSha": { + "type": "string" + }, + "startedAt": { + "type": "string" + } + }, + "required": [ + "_tag", + "headSha", + "startedAt" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Failed" + ] + }, + "headSha": { + "type": "string" + }, + "failedAt": { + "type": "string" + }, + "detail": { + "type": "string" + } + }, + "required": [ + "_tag", + "headSha", + "failedAt", + "detail" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "currentHeadSha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "guide", + "run", + "currentHeadSha" + ], + "additionalProperties": true + }, + "PullRequestsInvalidatePayload": { + "type": "object", + "properties": { + "reference": { + "anyOf": [ + { + "$ref": "#/$defs/PullRequestsActivityPayload" + }, + { + "type": "null" + } + ] + }, + "filesViewedOnly": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + "PullRequestsLabelCandidatesSuccess": { + "type": "object", + "properties": { + "candidates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "isApplied": { + "type": "boolean" + } + }, + "required": [ + "name", + "color", + "description", + "isApplied" + ], + "additionalProperties": true + } + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "candidates", + "truncated" + ], + "additionalProperties": true + }, + "PullRequestsLinkedThreadsSuccess": { + "type": "object", + "properties": { + "threads": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "archivedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "projectId", + "title", + "archivedAt" + ], + "additionalProperties": true + } + } + }, + "required": [ + "threads" + ], + "additionalProperties": true + }, + "PullRequestsListPayload": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "all", + "open", + "closed", + "merged" + ] + }, + "involvement": { + "anyOf": [ + { + "type": "string", + "enum": [ + "all", + "reviewing", + "authored" + ] + }, + { + "type": "null" + } + ] + }, + "filters": { + "anyOf": [ + { + "type": "object", + "properties": { + "draft": { + "anyOf": [ + { + "type": "string", + "enum": [ + "only", + "hide" + ] + }, + { + "type": "null" + } + ] + }, + "review": { + "anyOf": [ + { + "type": "string", + "enum": [ + "approved", + "changes-requested", + "review-required", + "none" + ] + }, + { + "type": "null" + } + ] + }, + "checks": { + "anyOf": [ + { + "type": "string", + "enum": [ + "passing", + "failing" + ] + }, + { + "type": "null" + } + ] + }, + "labels": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 10 + }, + "maxItems": 10 + }, + { + "type": "null" + } + ] + }, + "excludedLabels": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 10 + }, + { + "type": "null" + } + ] + }, + "author": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "projectId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "projectIds": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 100 + }, + { + "type": "null" + } + ] + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "limit": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 500 + }, + { + "type": "null" + } + ] + }, + "cursors": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "query": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "state" + ], + "additionalProperties": true + }, + "PullRequestsListStatsPayload": { + "type": "object", + "properties": { + "refs": { + "type": "array", + "items": { + "$ref": "#/$defs/PullRequestsActivityPayload" + }, + "maxItems": 500 + } + }, + "required": [ + "refs" + ], + "additionalProperties": true + }, + "PullRequestsListStatsSuccess": { + "type": "object", + "properties": { + "stats": { + "type": "array", + "items": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "additions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "deletions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "projectId", + "repository", + "number", + "additions", + "deletions" + ], + "additionalProperties": true + } + } + }, + "required": [ + "stats" + ], + "additionalProperties": true + }, + "PullRequestsListSuccess": { + "type": "object", + "properties": { + "viewers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "kind": { + "type": "string" + }, + "searchesOnHost": { + "type": "boolean" + }, + "actions": { + "anyOf": [ + { + "$ref": "#/$defs/Actions" + }, + { + "type": "null" + } + ] + }, + "projectCount": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "configured": { + "type": "boolean" + }, + "detail": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "host", + "kind", + "searchesOnHost", + "projectCount", + "configured", + "detail" + ], + "additionalProperties": true + } + }, + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "stack": { + "anyOf": [ + { + "$ref": "#/$defs/Stack2" + }, + { + "type": "null" + } + ] + }, + "provider": { + "type": "string" + }, + "host": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "projectTitle": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "author": { + "anyOf": [ + { + "$ref": "#/$defs/Author" + }, + { + "type": "null" + } + ] + }, + "headBranch": { + "type": "string" + }, + "baseBranch": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed", + "merged" + ] + }, + "isDraft": { + "type": "boolean" + }, + "mergeability": { + "type": "string", + "enum": [ + "mergeable", + "conflicting", + "unknown" + ] + }, + "additions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "deletions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "observedAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "viewerReviewRequested": { + "type": "boolean" + }, + "labels": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "color": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "color" + ], + "additionalProperties": true + } + }, + "reviewDecision": { + "anyOf": [ + { + "type": "string", + "enum": [ + "approved", + "changes-requested", + "review-required" + ] + }, + { + "type": "null" + } + ] + }, + "checksState": { + "anyOf": [ + { + "type": "string", + "enum": [ + "passing", + "failing", + "pending" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "provider", + "host", + "projectId", + "projectTitle", + "repository", + "number", + "title", + "url", + "author", + "headBranch", + "baseBranch", + "state", + "isDraft", + "mergeability", + "additions", + "deletions", + "createdAt", + "updatedAt", + "viewerReviewRequested", + "labels" + ], + "additionalProperties": true + } + }, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "projectTitle": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "projectId", + "projectTitle", + "message" + ], + "additionalProperties": true + } + }, + "truncated": { + "type": "boolean" + }, + "nextCursors": { + "type": "object", + "additionalProperties": { + "type": "string" + } + } + }, + "required": [ + "viewers", + "providers", + "entries", + "errors", + "truncated", + "nextCursors" + ], + "additionalProperties": true + }, + "PullRequestsPreviewSuccess": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "author": { + "anyOf": [ + { + "$ref": "#/$defs/Author" + }, + { + "type": "null" + } + ] + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed", + "merged" + ] + }, + "isDraft": { + "type": "boolean" + }, + "createdAt": { + "type": "string" + } + }, + "required": [ + "projectId", + "repository", + "number", + "title", + "url", + "author", + "state", + "isDraft", + "createdAt" + ], + "additionalProperties": true + }, + "PullRequestsReplyToThreadPayload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expectedAccountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "allowStale": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "threadId": { + "type": "string" + }, + "body": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + } + }, + "required": [ + "projectId", + "repository", + "number", + "threadId", + "body" + ], + "additionalProperties": true + }, + "PullRequestsReportStatePayload": { + "type": "object", + "properties": { + "reference": { + "$ref": "#/$defs/PullRequestsActivityPayload" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed", + "merged" + ] + }, + "isDraft": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "reference", + "state" + ], + "additionalProperties": true + }, + "PullRequestsRequestReviewersPayload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expectedAccountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "allowStale": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "reviewers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "user", + "team" + ] + } + }, + "required": [ + "id", + "kind" + ], + "additionalProperties": true + }, + "minItems": 1, + "maxItems": 25 + }, + "requested": { + "type": "boolean" + } + }, + "required": [ + "projectId", + "repository", + "number", + "reviewers", + "requested" + ], + "additionalProperties": true + }, + "PullRequestsReviewerCandidatesSuccess": { + "type": "object", + "properties": { + "candidates": { + "type": "array", + "items": { + "type": "object", + "properties": { + "isBot": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "login": { + "type": "string" + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "avatarUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "user", + "team" + ] + }, + "isRequested": { + "type": "boolean" + } + }, + "required": [ + "login", + "name", + "avatarUrl", + "id", + "kind", + "isRequested" + ], + "additionalProperties": true + } + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "candidates", + "truncated" + ], + "additionalProperties": true + }, + "PullRequestsRoutingIdentityPayload": { + "type": "object", + "properties": { + "host": { + "type": "string" + } + }, + "required": [ + "host" + ], + "additionalProperties": true + }, + "PullRequestsRoutingIdentitySuccess": { + "type": "object", + "properties": { + "accountId": { + "type": "string" + }, + "host": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "viewer": { + "type": "string" + } + }, + "required": [ + "accountId", + "host", + "provider", + "viewer" + ], + "additionalProperties": true + }, + "PullRequestsRoutingSuccess": { + "type": "object", + "properties": { + "accountId": { + "type": "string" + }, + "host": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "viewer": { + "type": "string" + }, + "projectTitle": { + "type": "string" + }, + "workspaceRoot": { + "type": "string" + } + }, + "required": [ + "accountId", + "host", + "provider", + "viewer", + "projectTitle", + "workspaceRoot" + ], + "additionalProperties": true + }, + "PullRequestsRunActionPayload": { + "type": "object", + "properties": { + "stackNumber": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "expectedStackHeads": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "headSha": { + "type": "string" + } + }, + "required": [ + "number", + "headSha" + ], + "additionalProperties": true + } + }, + { + "type": "null" + } + ] + }, + "projectId": { + "type": "string" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expectedAccountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "allowStale": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "action": { + "type": "string", + "enum": [ + "merge", + "ready", + "draft", + "close", + "reopen", + "delete-branch", + "update-branch", + "enable-auto-merge", + "disable-auto-merge", + "revert", + "approve-workflows" + ] + }, + "mergeMethod": { + "anyOf": [ + { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + }, + { + "type": "null" + } + ] + }, + "updateMethod": { + "anyOf": [ + { + "type": "string", + "enum": [ + "merge", + "rebase" + ] + }, + { + "type": "null" + } + ] + }, + "bypassRules": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "projectId", + "repository", + "number", + "action" + ], + "additionalProperties": true + }, + "PullRequestsSetFilesViewedPayload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expectedAccountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "allowStale": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "viewed": { + "type": "boolean" + } + }, + "required": [ + "path", + "viewed" + ], + "additionalProperties": true + }, + "maxItems": 500 + } + }, + "required": [ + "projectId", + "repository", + "number", + "files" + ], + "additionalProperties": true + }, + "PullRequestsSetLabelsPayload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expectedAccountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "allowStale": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "labels": { + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "maxItems": 25 + }, + "applied": { + "type": "boolean" + } + }, + "required": [ + "projectId", + "repository", + "number", + "labels", + "applied" + ], + "additionalProperties": true + }, + "PullRequestsSetReactionPayload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expectedAccountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "allowStale": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "subjectId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "content": { + "type": "string", + "enum": [ + "thumbs-up", + "thumbs-down", + "laugh", + "hooray", + "confused", + "heart", + "rocket", + "eyes" + ] + }, + "reacted": { + "type": "boolean" + } + }, + "required": [ + "projectId", + "repository", + "number", + "content", + "reacted" + ], + "additionalProperties": true + }, + "PullRequestsSetThreadResolutionPayload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expectedAccountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "allowStale": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "threadId": { + "type": "string" + }, + "resolved": { + "type": "boolean" + } + }, + "required": [ + "projectId", + "repository", + "number", + "threadId", + "resolved" + ], + "additionalProperties": true + }, + "PullRequestsStackSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "url": { + "type": "string" + }, + "base": { + "type": "string" + }, + "layers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "isDraft": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "headSha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "headBranch": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed", + "merged" + ] + } + }, + "required": [ + "number", + "headBranch", + "state" + ], + "additionalProperties": true + } + } + }, + "required": [ + "id", + "number", + "url", + "base", + "layers" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "PullRequestsSubmitReviewPayload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expectedAccountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "allowStale": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "verdict": { + "type": "string", + "enum": [ + "comment", + "approve", + "request-changes" + ] + }, + "body": { + "type": "string", + "maxLength": 65536 + }, + "comments": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "oldPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "position": { + "anyOf": [ + { + "$ref": "#/$defs/Position" + }, + { + "$ref": "#/$defs/Position2" + }, + { + "$ref": "#/$defs/Position3" + } + ] + }, + "startPosition": { + "anyOf": [ + { + "$ref": "#/$defs/Position" + }, + { + "$ref": "#/$defs/Position2" + }, + { + "$ref": "#/$defs/Position3" + }, + { + "type": "null" + } + ] + }, + "body": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + } + }, + "required": [ + "path", + "position", + "body" + ], + "additionalProperties": true + } + } + }, + "required": [ + "projectId", + "repository", + "number", + "verdict", + "body", + "comments" + ], + "additionalProperties": true + }, + "PullRequestsSubscribeRefreshesSuccess": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "PullRequestsSummarySuccess": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed", + "merged" + ] + }, + "isDraft": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "headBranch": { + "type": "string" + }, + "baseBranch": { + "type": "string" + }, + "closedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mergedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + }, + "observedAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ] + }, + "author": { + "anyOf": [ + { + "$ref": "#/$defs/Author" + }, + { + "type": "null" + } + ] + }, + "additions": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "deletions": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "changedFiles": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "reviewDecision": { + "anyOf": [ + { + "type": "string", + "enum": [ + "approved", + "changes-requested", + "review-required" + ] + }, + { + "type": "null" + } + ] + }, + "checksState": { + "anyOf": [ + { + "type": "string", + "enum": [ + "passing", + "failing", + "pending" + ] + }, + { + "type": "null" + } + ] + }, + "mergeability": { + "anyOf": [ + { + "type": "string", + "enum": [ + "mergeable", + "conflicting", + "unknown" + ] + }, + { + "type": "null" + } + ] + }, + "stack": { + "anyOf": [ + { + "$ref": "#/$defs/Stack2" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "provider", + "projectId", + "repository", + "number", + "title", + "url", + "state", + "headBranch", + "baseBranch", + "updatedAt" + ], + "additionalProperties": true + }, + "PullRequestsThreadCommentsPayload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expectedAccountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "allowStale": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "threadId": { + "type": "string" + }, + "cursor": { + "type": "string" + } + }, + "required": [ + "projectId", + "repository", + "number", + "threadId", + "cursor" + ], + "additionalProperties": true + }, + "PullRequestsThreadCommentsSuccess": { + "type": "object", + "properties": { + "comments": { + "$ref": "#/$defs/Comments" + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "comments", + "nextCursor" + ], + "additionalProperties": true + }, + "PullRequestsUpdateCommentPayload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expectedAccountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "allowStale": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "commentId": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "issue-comment", + "review-comment" + ] + }, + "body": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + } + }, + "required": [ + "projectId", + "repository", + "number", + "commentId", + "kind", + "body" + ], + "additionalProperties": true + }, + "PullRequestsUpdatePayload": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "host": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "expectedAccountId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "allowStale": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "repository": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "body": { + "anyOf": [ + { + "type": "string", + "maxLength": 65536 + }, + { + "type": "null" + } + ] + }, + "maintainerCanModify": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "projectId", + "repository", + "number" + ], + "additionalProperties": true + }, + "Reactions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "content": { + "type": "string", + "enum": [ + "thumbs-up", + "thumbs-down", + "laugh", + "hooray", + "confused", + "heart", + "rocket", + "eyes" + ] + }, + "count": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "actors": { + "type": "array", + "items": { + "type": "string" + } + }, + "viewerHasReacted": { + "type": "boolean" + } + }, + "required": [ + "content", + "count", + "actors", + "viewerHasReacted" + ], + "additionalProperties": true + } + }, + "Registration": { + "type": "object", + "properties": { + "clientId": { + "type": "string", + "pattern": "^oaiapp_[\\w-]+$" + }, + "subject": { + "type": "string" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "redirectUri": { + "type": "string", + "pattern": "^http:\\/\\/(?:127\\.0\\.0\\.1|localhost):[1-9]\\d{0,4}\\/auth\\/callback$" + }, + "connectionLabel": { + "type": "string" + }, + "sharingEnabled": { + "type": "boolean" + }, + "idTokenHint": { + "type": "string", + "maxLength": 16384 + } + }, + "required": [ + "clientId" + ], + "additionalProperties": true + }, + "RelayClientInstallFailedErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "RelayClientInstallFailedError" + ] + }, + "reason": { + "type": "string", + "enum": [ + "download_failed", + "invalid_checksum", + "install_locked", + "managed_by_policy", + "override_missing", + "unsupported_platform", + "validation_failed", + "write_failed" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "_tag", + "reason", + "message" + ], + "additionalProperties": true + }, + "Remote": { + "type": "object", + "properties": { + "hasUpstream": { + "type": "boolean" + }, + "aheadCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "behindCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "aheadOfDefaultCount": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pr": { + "anyOf": [ + { + "$ref": "#/$defs/Pr" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "hasUpstream", + "aheadCount", + "behindCount", + "pr" + ], + "additionalProperties": true + }, + "RemoteChild": { + "type": "object", + "properties": { + "environmentId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "label": { + "type": "string" + }, + "runId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "stoppedThere": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "stopCount": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "environmentId", + "threadId", + "label" + ], + "additionalProperties": true + }, + "Repository": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "nameWithOwner": { + "type": "string" + }, + "url": { + "type": "string" + }, + "sshUrl": { + "type": "string" + } + }, + "required": [ + "provider", + "nameWithOwner", + "url", + "sshUrl" + ], + "additionalProperties": true + }, + "RepositoryIdentity": { + "type": "object", + "properties": { + "canonicalKey": { + "type": "string" + }, + "locator": { + "type": "object", + "properties": { + "source": { + "type": "string", + "enum": [ + "git-remote" + ] + }, + "remoteName": { + "type": "string" + }, + "remoteUrl": { + "type": "string" + } + }, + "required": [ + "source", + "remoteName", + "remoteUrl" + ], + "additionalProperties": true + }, + "webUrl": { + "type": "string" + }, + "rootPath": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "owner": { + "type": "string" + }, + "name": { + "type": "string" + }, + "origin": { + "type": "object", + "properties": { + "canonicalKey": { + "type": "string" + }, + "remoteUrl": { + "type": "string" + }, + "displayName": { + "type": "string" + } + }, + "required": [ + "canonicalKey" + ], + "additionalProperties": true + } + }, + "required": [ + "canonicalKey", + "locator" + ], + "additionalProperties": true + }, + "Resource": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "workspace-file" + ] + }, + "threadId": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "_tag", + "threadId", + "path" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "media-file" + ] + }, + "threadId": { + "type": "string" + }, + "path": { + "type": "string" + }, + "disposition": { + "type": "string", + "enum": [ + "attachment" + ] + } + }, + "required": [ + "_tag", + "threadId", + "path" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "draft-workspace-file" + ] + }, + "cwd": { + "type": "string" + }, + "path": { + "type": "string" + } + }, + "required": [ + "_tag", + "cwd", + "path" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "attachment" + ] + }, + "attachmentId": { + "type": "string" + }, + "fileName": { + "type": "string" + }, + "mimeType": { + "type": "string" + }, + "disposition": { + "type": "string", + "enum": [ + "inline", + "attachment" + ] + }, + "renderIntent": { + "type": "string", + "enum": [ + "mcp-app" + ] + } + }, + "required": [ + "_tag", + "attachmentId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "tool-output-image" + ] + }, + "threadId": { + "type": "string" + }, + "itemId": { + "type": "string" + }, + "index": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "_tag", + "threadId", + "itemId", + "index" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "project-favicon" + ] + }, + "cwd": { + "type": "string" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "cwd" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "native-app-icon" + ] + }, + "app": { + "$ref": "#/$defs/App" + } + }, + "required": [ + "_tag", + "app" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "github-media" + ] + }, + "cwd": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "_tag", + "cwd", + "url" + ], + "additionalProperties": true + } + ] + }, + "ReviewGetBranchCommitsError": { + "anyOf": [ + { + "$ref": "#/$defs/VcsProcessSpawnErrorEncoded" + }, + { + "$ref": "#/$defs/VcsProcessExitErrorEncoded" + }, + { + "$ref": "#/$defs/VcsProcessTimeoutErrorEncoded" + }, + { + "$ref": "#/$defs/VcsProcessStdinWriteErrorEncoded" + }, + { + "$ref": "#/$defs/VcsProcessOutputReadErrorEncoded" + }, + { + "$ref": "#/$defs/VcsProcessOutputLimitErrorEncoded" + }, + { + "$ref": "#/$defs/VcsProcessMissingExitCodeErrorEncoded" + }, + { + "$ref": "#/$defs/VcsRepositoryDetectionErrorEncoded" + }, + { + "$ref": "#/$defs/VcsUnsupportedOperationErrorEncoded" + }, + { + "$ref": "#/$defs/GitCommandErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ReviewGetBranchCommitsPayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "baseRef": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "cwd" + ], + "additionalProperties": true + }, + "ReviewGetBranchCommitsSuccess": { + "type": "object", + "properties": { + "commits": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sha": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "committedAt": { + "type": "string" + } + }, + "required": [ + "sha", + "subject", + "committedAt" + ], + "additionalProperties": true + } + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "commits", + "truncated" + ], + "additionalProperties": true + }, + "ReviewGetDiffFileContentsPayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "sourceKind": { + "type": "string", + "enum": [ + "working-tree", + "branch-range", + "commit" + ] + }, + "changeType": { + "type": "string", + "enum": [ + "change", + "rename-pure", + "rename-changed", + "new", + "deleted" + ] + }, + "baseRef": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "headRef": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "oldPath": { + "type": "string" + }, + "newPath": { + "type": "string" + } + }, + "required": [ + "cwd", + "sourceKind", + "changeType", + "baseRef", + "headRef", + "oldPath", + "newPath" + ], + "additionalProperties": true + }, + "ReviewGetDiffPreviewPayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "baseRef": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "commitSha": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "ignoreWhitespace": { + "type": "boolean" + }, + "sourceKind": { + "type": "string", + "enum": [ + "working-tree", + "branch-range" + ] + }, + "trackedOnly": { + "type": "boolean" + }, + "metadataOnly": { + "type": "boolean" + }, + "file": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "previousPath": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "sourceKind": { + "type": "string", + "enum": [ + "working-tree", + "branch-range", + "commit" + ] + } + }, + "required": [ + "path", + "previousPath", + "sourceKind" + ], + "additionalProperties": true + } + }, + "required": [ + "cwd" + ], + "additionalProperties": true + }, + "ReviewGetDiffPreviewSuccess": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "generatedAt": { + "type": "string" + }, + "sources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "working-tree", + "branch-range", + "commit" + ] + }, + "title": { + "type": "string" + }, + "commitSubject": { + "type": "string" + }, + "baseRef": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "headRef": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "diff": { + "type": "string" + }, + "diffHash": { + "type": "string" + }, + "truncated": { + "type": "boolean" + }, + "patchesDeferred": { + "type": "boolean" + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "previousPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "additions": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "deletions": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "path", + "previousPath", + "additions", + "deletions" + ], + "additionalProperties": true + } + } + }, + "required": [ + "id", + "kind", + "title", + "baseRef", + "headRef", + "diff", + "diffHash", + "truncated" + ], + "additionalProperties": true + } + }, + "incomplete": { + "type": "boolean" + } + }, + "required": [ + "cwd", + "generatedAt", + "sources" + ], + "additionalProperties": true + }, + "Schedule3": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "fixed_time" + ] + }, + "timeOfDay": { + "type": "string" + }, + "weekdays": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 6, + "description": "Weekday. 0 is Sunday, 6 is Saturday." + }, + "description": "Default: every day." + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "timeOfDay" + ], + "additionalProperties": true + }, + "ScheduledTaskErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ScheduledTaskError" + ] + }, + "message": { + "type": "string" + }, + "taskId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "message" + ], + "additionalProperties": true + }, + "ScheduledTasksDeleteError": { + "anyOf": [ + { + "$ref": "#/$defs/ScheduledTaskErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ScheduledTasksDeletePayload": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": true + }, + "ScheduledTasksGetWebhookDeliveryPayload": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "deliveryId": { + "type": "string" + }, + "acceptAnyOutcome": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "deliveryId" + ], + "additionalProperties": true + }, + "ScheduledTasksGetWebhookDeliverySuccess": { + "type": "object", + "properties": { + "delivery": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "receivedAt": { + "type": "string" + }, + "method": { + "type": "string" + }, + "contentType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "bodyBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "outcome": { + "anyOf": [ + { + "type": "string", + "enum": [ + "accepted", + "dispatch_failed", + "duplicate", + "coalesced", + "rejected_signature", + "disabled", + "rate_limited", + "expired", + "filtered" + ] + }, + { + "type": "string" + } + ] + }, + "signatureVerified": { + "type": "boolean" + }, + "pendingWindow": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "missingFields": { + "type": "array", + "items": { + "type": "string" + } + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "query": { + "type": "string" + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "body": { + "type": "string" + }, + "bodyTruncated": { + "type": "boolean" + }, + "renderedPrompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "taskId", + "receivedAt", + "method", + "contentType", + "bodyBytes", + "outcome", + "signatureVerified", + "missingFields", + "error", + "query", + "headers", + "body", + "bodyTruncated", + "renderedPrompt" + ], + "additionalProperties": true + } + }, + "required": [ + "delivery" + ], + "additionalProperties": true + }, + "ScheduledTasksListWebhookDeliveriesPayload": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "acceptAnyOutcome": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id" + ], + "additionalProperties": true + }, + "ScheduledTasksListWebhookDeliveriesSuccess": { + "type": "object", + "properties": { + "deliveries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "taskId": { + "type": "string" + }, + "receivedAt": { + "type": "string" + }, + "method": { + "type": "string" + }, + "contentType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "bodyBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "outcome": { + "anyOf": [ + { + "type": "string", + "enum": [ + "accepted", + "dispatch_failed", + "duplicate", + "coalesced", + "rejected_signature", + "disabled", + "rate_limited", + "expired", + "filtered" + ] + }, + { + "type": "string" + } + ] + }, + "signatureVerified": { + "type": "boolean" + }, + "pendingWindow": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "missingFields": { + "type": "array", + "items": { + "type": "string" + } + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "taskId", + "receivedAt", + "method", + "contentType", + "bodyBytes", + "outcome", + "signatureVerified", + "missingFields", + "error" + ], + "additionalProperties": true + } + } + }, + "required": [ + "deliveries" + ], + "additionalProperties": true + }, + "ScheduledTasksRotateWebhookTokenSuccess": { + "type": "object", + "properties": { + "task": { + "$ref": "#/$defs/Task" + } + }, + "required": [ + "task" + ], + "additionalProperties": true + }, + "ScheduledTasksSetEnabledPayload": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "enabled": { + "type": "boolean" + } + }, + "required": [ + "id", + "enabled" + ], + "additionalProperties": true + }, + "ScheduledTasksSubscribeSuccess": { + "type": "object", + "properties": { + "tasks": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Task" + }, + { + "type": "null" + } + ] + } + } + }, + "required": [ + "tasks" + ], + "additionalProperties": true + }, + "ScheduledTasksUpsertPayload": { + "type": "object", + "properties": { + "id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "requireExisting": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "description": "Reject the save if the task no longer exists, for edits from a client form." + }, + "commandId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "title": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "schedule": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "interval" + ] + }, + "everyMs": { + "type": "integer", + "maximum": 9007199254740991, + "minimum": 60000, + "description": "Min 60000." + } + }, + "required": [ + "type", + "everyMs" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/Schedule3" + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "once" + ] + }, + "runAt": { + "type": "string" + } + }, + "required": [ + "type", + "runAt" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "webhook" + ] + }, + "signature": { + "anyOf": [ + { + "type": "object", + "properties": { + "header": { + "type": "string" + }, + "encoding": { + "type": "string", + "enum": [ + "hex", + "base64" + ] + }, + "prefix": { + "type": "string", + "description": "Text before the digest, such as 'sha256='. Empty for none." + }, + "secret": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Default: stored secret." + }, + "secretRef": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "From `request_secret`. Instead of `secret`. Spent by this save." + } + }, + "required": [ + "header", + "encoding", + "prefix" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ], + "description": "Omit for URL token only." + }, + "maxDeliveryAgeMinutes": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 1440, + "description": "Skip requests held offline over this many minutes. Omit to run all." + }, + { + "type": "null" + } + ] + }, + "dedupe": { + "anyOf": [ + { + "$ref": "#/$defs/Dedupe" + }, + { + "type": "null" + } + ] + }, + "filters": { + "anyOf": [ + { + "$ref": "#/$defs/Filters" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": true, + "description": "Run per webhook request. Prompt can use {{body.path}}, {{headers.name}}, {{query.name}}, {{body}}, {{request}}." + } + ] + }, + "projectId": { + "type": "string" + }, + "threadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspaceStrategy": { + "$ref": "#/$defs/WorkspaceStrategy3" + }, + "modelSelection": { + "$ref": "#/$defs/ModelSelection" + }, + "runtimeMode": { + "type": "string", + "enum": [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + "interactionMode": { + "type": "string", + "enum": [ + "default", + "plan" + ] + }, + "createdBy": { + "anyOf": [ + { + "type": "string", + "enum": [ + "user", + "agent", + "system" + ] + }, + { + "type": "null" + } + ] + }, + "creationSource": { + "anyOf": [ + { + "type": "string", + "enum": [ + "web", + "mobile", + "mcp", + "provider", + "server" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "title", + "prompt", + "enabled", + "schedule", + "projectId", + "workspaceStrategy", + "modelSelection", + "runtimeMode", + "interactionMode" + ], + "additionalProperties": true + }, + "Scopes": { + "type": "array", + "items": { + "$ref": "#/$defs/PermissionsEntry" + } + }, + "SecretRequestErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SecretRequestError" + ] + }, + "reason": { + "type": "string", + "enum": [ + "load_failed", + "not_found", + "already_answered", + "agent_stopped", + "store_failed", + "record_failed", + "invalid_ref", + "read_failed", + "ref_unavailable", + "ref_expired", + "consume_failed", + "not_a_file_request" + ] + }, + "cause": {} + }, + "required": [ + "_tag", + "reason" + ], + "additionalProperties": true + }, + "SecretsAnswerRequestError": { + "anyOf": [ + { + "$ref": "#/$defs/SecretRequestErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "SecretsAnswerRequestPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "turnItemId": { + "type": "string" + }, + "answer": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "save" + ] + }, + "secret": { + "type": "string" + } + }, + "required": [ + "type", + "secret" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "decline" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": true + } + ] + } + }, + "required": [ + "threadId", + "turnItemId", + "answer" + ], + "additionalProperties": true + }, + "ServerAcceptAcpRegistryUrlAuthPayload": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "elicitationId": { + "type": "string" + } + }, + "required": [ + "instanceId", + "elicitationId" + ], + "additionalProperties": true + }, + "ServerAcceptAcpRegistryUrlAuthSuccess": { + "type": "object", + "properties": { + "accepted": { + "type": "boolean" + } + }, + "required": [ + "accepted" + ], + "additionalProperties": true + }, + "ServerCheckOtlpEndpointPayload": { + "type": "object", + "properties": { + "signal": { + "type": "string", + "enum": [ + "traces", + "metrics", + "logs" + ] + }, + "url": { + "type": "string" + } + }, + "required": [ + "signal", + "url" + ], + "additionalProperties": true + }, + "ServerCheckOtlpEndpointSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Accepted" + ] + }, + "latencyMs": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "_tag", + "latencyMs" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Rejected" + ] + }, + "status": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "_tag", + "status" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Unreachable" + ] + }, + "timedOut": { + "type": "boolean" + } + }, + "required": [ + "_tag", + "timedOut" + ], + "additionalProperties": true + } + ] + }, + "ServerCheckSecretSourcePayload": { + "type": "object", + "properties": { + "source": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "reference": { + "type": "string" + } + }, + "required": [ + "provider", + "reference" + ], + "additionalProperties": true + } + }, + "required": [ + "source" + ], + "additionalProperties": true + }, + "ServerCheckSecretSourceSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Resolved" + ] + } + }, + "required": [ + "_tag" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Failed" + ] + }, + "detail": { + "type": "string" + } + }, + "required": [ + "_tag", + "detail" + ], + "additionalProperties": true + } + ] + }, + "ServerCommitDesktopUpdateError": { + "anyOf": [ + { + "$ref": "#/$defs/ServerSelfUpdateErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ServerCommitDesktopUpdatePayload": { + "type": "object", + "properties": { + "requestId": { + "type": "string" + } + }, + "required": [ + "requestId" + ], + "additionalProperties": true + }, + "ServerCommitDesktopUpdateSuccess": { + "type": "object", + "properties": { + "targetVersion": { + "type": "string" + }, + "method": { + "type": "string", + "enum": [ + "boot-service", + "respawn", + "desktop-app" + ] + }, + "updateId": { + "type": "string" + }, + "desktopUpdateToken": { + "type": "string" + } + }, + "required": [ + "targetVersion", + "method" + ], + "additionalProperties": true + }, + "ServerDeleteAcpRegistrySessionError": { + "anyOf": [ + { + "$ref": "#/$defs/AcpRegistryOperationErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ServerDeleteAcpRegistrySessionPayload": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "sessionId": { + "type": "string" + } + }, + "required": [ + "instanceId", + "projectId", + "sessionId" + ], + "additionalProperties": true + }, + "ServerDeleteAcpRegistrySessionSuccess": { + "type": "object", + "properties": { + "deleted": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "deleted" + ], + "additionalProperties": true + }, + "ServerDisableAcpRegistryProviderPayload": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "providerId": { + "type": "string" + } + }, + "required": [ + "instanceId", + "projectId", + "providerId" + ], + "additionalProperties": true + }, + "ServerDisableAcpRegistryProviderSuccess": { + "type": "object", + "properties": { + "disabled": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "disabled" + ], + "additionalProperties": true + }, + "ServerDiscoverSourceControlSuccess": { + "type": "object", + "properties": { + "versionControlSystems": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "git", + "jj", + "unknown" + ] + }, + "implemented": { + "type": "boolean" + }, + "label": { + "type": "string" + }, + "executable": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "available", + "missing" + ] + }, + "version": { + "$ref": "#/$defs/LastError" + }, + "installHint": { + "type": "string" + }, + "detail": { + "$ref": "#/$defs/LastError" + } + }, + "required": [ + "kind", + "implemented", + "label", + "status", + "version", + "installHint", + "detail" + ], + "additionalProperties": true + } + }, + "sourceControlProviders": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "label": { + "type": "string" + }, + "executable": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "available", + "missing" + ] + }, + "version": { + "$ref": "#/$defs/LastError" + }, + "installHint": { + "type": "string" + }, + "detail": { + "$ref": "#/$defs/LastError" + }, + "auth": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "authenticated", + "unauthenticated", + "unknown" + ] + }, + "account": { + "$ref": "#/$defs/LastError" + }, + "host": { + "$ref": "#/$defs/LastError" + }, + "detail": { + "$ref": "#/$defs/LastError" + }, + "accounts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "account": { + "type": "string" + }, + "active": { + "type": "boolean" + }, + "authenticated": { + "type": "boolean" + }, + "error": { + "type": "string" + }, + "environmentVariable": { + "type": "string" + } + }, + "required": [ + "host", + "account", + "active", + "authenticated" + ], + "additionalProperties": true + } + } + }, + "required": [ + "status", + "account", + "host", + "detail" + ], + "additionalProperties": true + }, + "appSignIn": { + "type": "boolean" + } + }, + "required": [ + "kind", + "label", + "status", + "version", + "installHint", + "detail", + "auth" + ], + "additionalProperties": true + } + } + }, + "required": [ + "versionControlSystems", + "sourceControlProviders" + ], + "additionalProperties": true + }, + "ServerGetConfigError": { + "anyOf": [ + { + "$ref": "#/$defs/KeybindingsConfigParseErrorEncoded" + }, + { + "$ref": "#/$defs/ServerSettingsErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ServerGetConfigSuccess": { + "type": "object", + "properties": { + "environment": { + "$ref": "#/$defs/Environment" + }, + "auth": { + "type": "object", + "properties": { + "policy": { + "type": "string", + "enum": [ + "desktop-managed-local", + "loopback-browser", + "remote-reachable", + "unsafe-no-auth" + ] + }, + "bootstrapMethods": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "desktop-bootstrap", + "one-time-token" + ] + } + }, + "sessionMethods": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "browser-session-cookie", + "bearer-access-token", + "dpop-access-token" + ] + } + }, + "sessionCookieName": { + "type": "string" + }, + "serverUpdateScope": { + "type": "string", + "enum": [ + "environment:maintain" + ] + } + }, + "required": [ + "policy", + "bootstrapMethods", + "sessionMethods", + "sessionCookieName" + ], + "additionalProperties": true + }, + "cwd": { + "type": "string" + }, + "keybindingsConfigPath": { + "type": "string" + }, + "keybindings": { + "$ref": "#/$defs/Keybindings" + }, + "issues": { + "$ref": "#/$defs/Issues" + }, + "providers": { + "$ref": "#/$defs/Providers" + }, + "availableEditors": { + "type": "array", + "items": { + "anyOf": [ + { + "$ref": "#/$defs/Editor" + }, + { + "type": "null" + } + ] + } + }, + "remoteOpenTargets": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "tailscale", + "mdns" + ] + }, + "host": { + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": [ + "kind", + "host" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "directEndpoints": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "lan", + "tailnet" + ] + }, + "httpBaseUrl": { + "type": "string" + } + }, + "required": [ + "kind", + "httpBaseUrl" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "observability": { + "type": "object", + "properties": { + "logsDirectoryPath": { + "type": "string" + }, + "localTracingEnabled": { + "type": "boolean" + }, + "otlpTracesUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "otlpTracesSource": { + "anyOf": [ + { + "type": "string", + "enum": [ + "environment", + "bootstrap", + "settings" + ] + }, + { + "type": "null" + } + ] + }, + "otlpTracesVariable": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "otlpTracesEnabled": { + "type": "boolean" + }, + "otlpMetricsUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "otlpMetricsSource": { + "anyOf": [ + { + "type": "string", + "enum": [ + "environment", + "bootstrap", + "settings" + ] + }, + { + "type": "null" + } + ] + }, + "otlpMetricsVariable": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "otlpMetricsEnabled": { + "type": "boolean" + }, + "otlpLogsUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "otlpLogsSource": { + "anyOf": [ + { + "type": "string", + "enum": [ + "environment", + "bootstrap", + "settings" + ] + }, + { + "type": "null" + } + ] + }, + "otlpLogsVariable": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "otlpLogsEnabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "logsDirectoryPath", + "localTracingEnabled", + "otlpTracesEnabled", + "otlpMetricsEnabled" + ], + "additionalProperties": true + }, + "settings": { + "$ref": "#/$defs/Settings2" + }, + "managedPolicy": { + "type": "object", + "properties": { + "organization": { + "type": "string" + }, + "supportUrl": { + "type": "string" + }, + "lockedSettings": { + "type": "array", + "items": { + "type": "string" + } + }, + "limits": { + "type": "object", + "properties": { + "maxRuntimeMode": { + "type": "string", + "enum": [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + "clients": { + "type": "array", + "prefixItems": [ + { + "type": "string", + "enum": [ + "desktop", + "local-browser", + "remote" + ] + } + ], + "minItems": 1, + "items": { + "type": "string", + "enum": [ + "desktop", + "local-browser", + "remote" + ] + } + }, + "connect": { + "type": "boolean" + }, + "webhookTasks": { + "type": "boolean" + }, + "telemetry": { + "type": "boolean" + }, + "browserCookieImport": { + "type": "boolean" + }, + "providers": { + "type": "object", + "properties": { + "allowedDrivers": { + "type": "array", + "prefixItems": [ + { + "type": "string" + } + ], + "minItems": 1, + "items": { + "type": "string" + } + }, + "lockedInstanceFields": { + "type": "object", + "additionalProperties": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "additionalProperties": true + }, + "updates": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "auto", + "off" + ] + }, + "allowNightly": { + "type": "boolean" + } + }, + "required": [ + "mode" + ], + "additionalProperties": true + } + }, + "additionalProperties": true + } + }, + "required": [ + "organization", + "lockedSettings" + ], + "additionalProperties": true + }, + "shellResumeCompletionMarker": { + "type": "boolean" + }, + "shellThreadFamilies": { + "type": "boolean" + }, + "shellOlderSettled": { + "type": "boolean" + }, + "shellRevealInFileManager": { + "type": "boolean" + }, + "shellRevealInFileManagerKind": { + "type": "string", + "enum": [ + "finder", + "file-explorer", + "files" + ] + }, + "threadResumeCompletionMarker": { + "type": "boolean" + }, + "threadSnapshotPagination": { + "type": "boolean" + }, + "threadFind": { + "type": "boolean" + }, + "threadFindProgressive": { + "type": "boolean" + }, + "secretProviderSettings": { + "type": "boolean" + }, + "secretReferences": { + "type": "boolean" + }, + "scratchWorkspaceRoot": { + "type": "string" + }, + "newProjectsRoot": { + "type": "string" + }, + "lastEventLoopStall": { + "type": "object", + "properties": { + "detectedAt": { + "type": "string" + }, + "durationMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "loadAverage": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "cpuCount": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "required": [ + "detectedAt", + "durationMs", + "cpuCount" + ], + "additionalProperties": true + }, + "environmentThemes": { + "anyOf": [ + { + "$ref": "#/$defs/EnvironmentThemes" + }, + { + "type": "null" + } + ] + }, + "usageLimitSources": { + "anyOf": [ + { + "$ref": "#/$defs/UsageLimitSources3" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "environment", + "auth", + "cwd", + "keybindingsConfigPath", + "keybindings", + "issues", + "providers", + "availableEditors", + "observability", + "settings" + ], + "additionalProperties": true + }, + "ServerGetDatabaseStorageSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "databaseBytes": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "reclaimableBytes": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "incrementalVacuumEnabled": { + "type": "boolean" + }, + "compactCommand": { + "type": "string" + } + }, + "required": [ + "databaseBytes", + "reclaimableBytes", + "incrementalVacuumEnabled", + "compactCommand" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "ServerGetHostResourcesSuccess": { + "type": "object", + "properties": { + "sampledAt": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "cpuUtilization": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "cpuCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "availableMemoryBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "totalMemoryBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "sampledAt", + "cpuUtilization", + "cpuCount", + "availableMemoryBytes", + "totalMemoryBytes" + ], + "additionalProperties": true + }, + "ServerGetNpmReleaseError": { + "anyOf": [ + { + "$ref": "#/$defs/ServerNpmReleaseErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ServerGetNpmReleasePayload": { + "type": "object", + "properties": { + "spec": { + "type": "string", + "maxLength": 64 + } + }, + "required": [ + "spec" + ], + "additionalProperties": true + }, + "ServerGetNpmReleaseSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "version": { + "type": "string" + }, + "databaseMigration": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "version" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "ServerGetProcessDiagnosticsSuccess": { + "type": "object", + "properties": { + "serverPid": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "readAt": { + "type": "string" + }, + "processCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "totalRssBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "totalCpuPercent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "processes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "pid": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "startTimeMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "ppid": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "pgid": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Some" + ] + }, + "value": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "_tag", + "value" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "None" + ] + } + }, + "required": [ + "_tag" + ], + "additionalProperties": true + } + ] + }, + "status": { + "type": "string" + }, + "cpuPercent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "rssBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "elapsed": { + "type": "string" + }, + "command": { + "type": "string" + }, + "depth": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "childPids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + }, + "required": [ + "pid", + "startTimeMs", + "ppid", + "pgid", + "status", + "cpuPercent", + "rssBytes", + "elapsed", + "command", + "depth", + "childPids" + ], + "additionalProperties": true + } + }, + "error": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Some" + ] + }, + "value": { + "type": "object", + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "additionalProperties": true + } + }, + "required": [ + "_tag", + "value" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "None" + ] + } + }, + "required": [ + "_tag" + ], + "additionalProperties": true + } + ] + } + }, + "required": [ + "serverPid", + "readAt", + "processCount", + "totalRssBytes", + "totalCpuPercent", + "processes", + "error" + ], + "additionalProperties": true + }, + "ServerGetProcessResourceHistoryPayload": { + "type": "object", + "properties": { + "windowMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "bucketMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "windowMs", + "bucketMs" + ], + "additionalProperties": true + }, + "ServerGetProcessResourceHistorySuccess": { + "type": "object", + "properties": { + "readAt": { + "type": "string" + }, + "windowMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "bucketMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "sampleIntervalMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "retainedSampleCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "totalCpuSecondsApprox": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "buckets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "startedAt": { + "type": "string" + }, + "endedAt": { + "type": "string" + }, + "avgCpuPercent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "maxCpuPercent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "maxRssBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "maxProcessCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "startedAt", + "endedAt", + "avgCpuPercent", + "maxCpuPercent", + "maxRssBytes", + "maxProcessCount" + ], + "additionalProperties": true + } + }, + "topProcesses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "processKey": { + "type": "string" + }, + "pid": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "ppid": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "command": { + "type": "string" + }, + "depth": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "isServerRoot": { + "type": "boolean" + }, + "firstSeenAt": { + "type": "string" + }, + "lastSeenAt": { + "type": "string" + }, + "currentCpuPercent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "avgCpuPercent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "maxCpuPercent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "cpuSecondsApprox": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "currentRssBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "maxRssBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "sampleCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "processKey", + "pid", + "ppid", + "command", + "depth", + "isServerRoot", + "firstSeenAt", + "lastSeenAt", + "currentCpuPercent", + "avgCpuPercent", + "maxCpuPercent", + "cpuSecondsApprox", + "currentRssBytes", + "maxRssBytes", + "sampleCount" + ], + "additionalProperties": true + } + }, + "error": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Some" + ] + }, + "value": { + "type": "object", + "properties": { + "failureTag": { + "type": "string", + "enum": [ + "ProcessDiagnosticsQueryTimeoutError", + "ProcessDiagnosticsQueryFailedError", + "ProcessDiagnosticsServerProcessSignalError", + "ProcessDiagnosticsNotDescendantError", + "ProcessDiagnosticsSignalFailedError" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "failureTag", + "message" + ], + "additionalProperties": true + } + }, + "required": [ + "_tag", + "value" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "None" + ] + } + }, + "required": [ + "_tag" + ], + "additionalProperties": true + } + ] + } + }, + "required": [ + "readAt", + "windowMs", + "bucketMs", + "sampleIntervalMs", + "retainedSampleCount", + "totalCpuSecondsApprox", + "buckets", + "topProcesses", + "error" + ], + "additionalProperties": true + }, + "ServerGetResourceTelemetryHistorySuccess": { + "type": "object", + "properties": { + "readAt": { + "type": "string" + }, + "windowMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "bucketMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "sampleIntervalMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "retainedSampleCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "buckets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "startedAt": { + "type": "string" + }, + "endedAt": { + "type": "string" + }, + "avgCpuPercent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "maxCpuPercent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "maxRssBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "ioReadBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "ioWriteBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "maxProcessCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "startedAt", + "endedAt", + "avgCpuPercent", + "maxCpuPercent", + "maxRssBytes", + "ioReadBytes", + "ioWriteBytes", + "maxProcessCount" + ], + "additionalProperties": true + } + }, + "topProcesses": { + "type": "array", + "items": { + "type": "object", + "properties": { + "identity": { + "$ref": "#/$defs/Identity2" + }, + "ppid": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "depth": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "command": { + "type": "string" + }, + "category": { + "type": "string", + "enum": [ + "server", + "server-child", + "provider-root", + "terminal-root", + "electron-main", + "electron-renderer", + "electron-gpu", + "electron-utility", + "resource-monitor", + "unknown-t3" + ] + }, + "firstSeenAt": { + "type": "string" + }, + "lastSeenAt": { + "type": "string" + }, + "currentCpuPercent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "avgCpuPercent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "maxCpuPercent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "cpuTimeMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "currentRssBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "peakRssBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "ioReadBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "ioWriteBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "ioSemantics": { + "type": "string", + "enum": [ + "storage", + "logical", + "all-io", + "unavailable" + ] + }, + "sampleCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "identity", + "ppid", + "depth", + "name", + "command", + "category", + "firstSeenAt", + "lastSeenAt", + "currentCpuPercent", + "avgCpuPercent", + "maxCpuPercent", + "cpuTimeMs", + "currentRssBytes", + "peakRssBytes", + "ioReadBytes", + "ioWriteBytes", + "ioSemantics", + "sampleCount" + ], + "additionalProperties": true + } + }, + "health": { + "$ref": "#/$defs/Health" + } + }, + "required": [ + "readAt", + "windowMs", + "bucketMs", + "sampleIntervalMs", + "retainedSampleCount", + "buckets", + "topProcesses", + "health" + ], + "additionalProperties": true + }, + "ServerGetStorageCleanupReportSuccess": { + "anyOf": [ + { + "$ref": "#/$defs/Shape" + }, + { + "type": "null" + } + ] + }, + "ServerGetTraceDiagnosticsSuccess": { + "type": "object", + "properties": { + "traceFilePath": { + "type": "string" + }, + "scannedFilePaths": { + "type": "array", + "items": { + "type": "string" + } + }, + "readAt": { + "type": "string" + }, + "recordCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "parseErrorCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "firstSpanAt": { + "$ref": "#/$defs/LastError" + }, + "lastSpanAt": { + "$ref": "#/$defs/LastError" + }, + "failureCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "interruptionCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "slowSpanThresholdMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "slowSpanCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "logLevelCounts": { + "type": "object", + "additionalProperties": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "topSpansByCount": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "failureCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "totalDurationMs": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "averageDurationMs": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "maxDurationMs": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "name", + "count", + "failureCount", + "totalDurationMs", + "averageDurationMs", + "maxDurationMs" + ], + "additionalProperties": true + } + }, + "slowestSpans": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "durationMs": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "endedAt": { + "type": "string" + }, + "traceId": { + "type": "string" + }, + "spanId": { + "type": "string" + } + }, + "required": [ + "name", + "durationMs", + "endedAt", + "traceId", + "spanId" + ], + "additionalProperties": true + } + }, + "commonFailures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "cause": { + "type": "string" + }, + "count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "lastSeenAt": { + "type": "string" + }, + "traceId": { + "type": "string" + }, + "spanId": { + "type": "string" + } + }, + "required": [ + "name", + "cause", + "count", + "lastSeenAt", + "traceId", + "spanId" + ], + "additionalProperties": true + } + }, + "latestFailures": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "cause": { + "type": "string" + }, + "durationMs": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "endedAt": { + "type": "string" + }, + "traceId": { + "type": "string" + }, + "spanId": { + "type": "string" + } + }, + "required": [ + "name", + "cause", + "durationMs", + "endedAt", + "traceId", + "spanId" + ], + "additionalProperties": true + } + }, + "latestWarningAndErrorLogs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "spanName": { + "type": "string" + }, + "level": { + "type": "string" + }, + "message": { + "type": "string" + }, + "seenAt": { + "type": "string" + }, + "traceId": { + "type": "string" + }, + "spanId": { + "type": "string" + } + }, + "required": [ + "spanName", + "level", + "message", + "seenAt", + "traceId", + "spanId" + ], + "additionalProperties": true + } + }, + "partialFailure": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Some" + ] + }, + "value": { + "type": "boolean" + } + }, + "required": [ + "_tag", + "value" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "None" + ] + } + }, + "required": [ + "_tag" + ], + "additionalProperties": true + } + ] + }, + "error": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Some" + ] + }, + "value": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "trace-file-not-found", + "trace-file-read-failed" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "kind", + "message" + ], + "additionalProperties": true + } + }, + "required": [ + "_tag", + "value" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "None" + ] + } + }, + "required": [ + "_tag" + ], + "additionalProperties": true + } + ] + } + }, + "required": [ + "traceFilePath", + "scannedFilePaths", + "readAt", + "recordCount", + "parseErrorCount", + "firstSpanAt", + "lastSpanAt", + "failureCount", + "interruptionCount", + "slowSpanThresholdMs", + "slowSpanCount", + "logLevelCounts", + "topSpansByCount", + "slowestSpans", + "commonFailures", + "latestFailures", + "latestWarningAndErrorLogs", + "partialFailure", + "error" + ], + "additionalProperties": true + }, + "ServerGetUsageSummaryError": { + "anyOf": [ + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + }, + { + "$ref": "#/$defs/UsageReadErrorEncoded" + } + ] + }, + "ServerGetUsageSummaryPayload": { + "type": "object", + "properties": { + "sinceDay": { + "type": "string" + }, + "untilDay": { + "type": "string" + }, + "timeZone": { + "type": "string" + }, + "resolution": { + "anyOf": [ + { + "type": "string", + "enum": [ + "day", + "hour" + ] + }, + { + "type": "null" + } + ] + }, + "sinceTime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "untilTime": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "awaitRefresh": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "modelFilter": { + "anyOf": [ + { + "$ref": "#/$defs/ModelFilter" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "sinceDay", + "untilDay", + "timeZone" + ], + "additionalProperties": true + }, + "ServerGetUsageSummarySuccess": { + "type": "object", + "properties": { + "contractVersion": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "readAt": { + "type": "string" + }, + "timeZone": { + "type": "string" + }, + "sinceDay": { + "type": "string" + }, + "untilDay": { + "type": "string" + }, + "buckets": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "day": { + "type": "string" + }, + "hourStart": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "provider": { + "type": "string", + "enum": [ + "claude", + "codex", + "grok", + "cursor", + "opencode", + "antigravity", + "pi", + "muse" + ] + }, + "model": { + "type": "string" + }, + "sourcePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "totals": { + "type": "object", + "properties": { + "uncachedInputTokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "cachedInputTokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "cacheCreationTokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "outputTokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "reasoningTokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "uncachedInputTokens", + "cachedInputTokens", + "cacheCreationTokens", + "outputTokens", + "reasoningTokens" + ], + "additionalProperties": true + }, + "costUsd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "cacheSavingsUsd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "categoryCostUsd": { + "anyOf": [ + { + "type": "object", + "properties": { + "input": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "cacheRead": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "cacheWrite": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "output": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "input", + "cacheRead", + "cacheWrite", + "output" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "fastCostUsd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "ultrafastCostUsd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "speedPremiumUsd": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "costSource": { + "type": "string", + "enum": [ + "providerReported", + "modelPriced", + "unpriced" + ] + }, + "records": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "unpricedRecords": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "sessions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "day", + "provider", + "model", + "totals", + "costUsd", + "cacheSavingsUsd", + "costSource", + "records", + "unpricedRecords", + "sessions" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "sources": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "fingerprint": { + "type": "object", + "properties": { + "hostId": { + "type": "string" + }, + "provider": { + "type": "string", + "enum": [ + "claude", + "codex", + "grok", + "cursor", + "opencode", + "antigravity", + "pi", + "muse" + ] + }, + "resolvedHomePath": { + "type": "string" + }, + "volumeId": { + "type": "string" + } + }, + "required": [ + "hostId", + "provider", + "resolvedHomePath", + "volumeId" + ], + "additionalProperties": true + }, + "status": { + "type": "string", + "enum": [ + "ok", + "missing", + "partial", + "failed" + ] + }, + "scannedFiles": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "skippedFiles": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "malformedRecords": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "distinctSessions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "action": { + "type": "string", + "enum": [ + "enableCursorKeychain" + ] + }, + "refreshing": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "fingerprint", + "status", + "scannedFiles", + "skippedFiles", + "malformedRecords", + "distinctSessions", + "message" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "pricing": { + "$ref": "#/$defs/Pricing" + }, + "scanDurationMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "modelFilter": { + "anyOf": [ + { + "$ref": "#/$defs/ModelFilter" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "contractVersion", + "readAt", + "timeZone", + "sinceDay", + "untilDay", + "buckets", + "sources", + "pricing", + "scanDurationMs" + ], + "additionalProperties": true + }, + "ServerImportAcpRegistrySessionPayload": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "sessionId": { + "type": "string" + }, + "title": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "instanceId", + "projectId", + "sessionId" + ], + "additionalProperties": true + }, + "ServerImportAcpRegistrySessionSuccess": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "imported": { + "type": "boolean" + } + }, + "required": [ + "threadId", + "imported" + ], + "additionalProperties": true + }, + "ServerListAcpRegistryProvidersPayload": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "projectId": { + "type": "string" + } + }, + "required": [ + "instanceId", + "projectId" + ], + "additionalProperties": true + }, + "ServerListAcpRegistryProvidersSuccess": { + "type": "object", + "properties": { + "providers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "providerId": { + "type": "string" + }, + "supported": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 16 + }, + "required": { + "type": "boolean" + }, + "current": { + "anyOf": [ + { + "type": "object", + "properties": { + "apiType": { + "type": "string" + }, + "baseUrl": { + "type": "string", + "maxLength": 2048 + } + }, + "required": [ + "apiType", + "baseUrl" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "providerId", + "supported", + "required", + "current" + ], + "additionalProperties": true + }, + "maxItems": 64 + } + }, + "required": [ + "providers" + ], + "additionalProperties": true + }, + "ServerListAcpRegistrySessionsPayload": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "cursor": { + "type": "string", + "maxLength": 2048 + } + }, + "required": [ + "instanceId", + "projectId" + ], + "additionalProperties": true + }, + "ServerListAcpRegistrySessionsSuccess": { + "type": "object", + "properties": { + "sessions": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "additionalDirectories": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 32 + }, + "title": { + "anyOf": [ + { + "type": "string", + "maxLength": 1024 + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "anyOf": [ + { + "type": "string", + "maxLength": 128 + }, + { + "type": "null" + } + ] + }, + "importedThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "sessionId", + "cwd", + "additionalDirectories", + "title", + "updatedAt", + "importedThreadId" + ], + "additionalProperties": true + }, + "maxItems": 256 + }, + "nextCursor": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048 + }, + { + "type": "null" + } + ] + }, + "canLoad": { + "type": "boolean" + }, + "canResume": { + "type": "boolean" + }, + "canDelete": { + "type": "boolean" + } + }, + "required": [ + "sessions", + "nextCursor", + "canLoad", + "canResume", + "canDelete" + ], + "additionalProperties": true + }, + "ServerListOnePasswordAccountsSuccess": { + "type": "array", + "items": { + "type": "object", + "properties": { + "account": { + "type": "string" + }, + "email": { + "type": "string" + } + }, + "required": [ + "account", + "email" + ], + "additionalProperties": true + } + }, + "ServerLogoutAcpRegistrySuccess": { + "type": "object", + "properties": { + "loggedOut": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "loggedOut" + ], + "additionalProperties": true + }, + "ServerNpmReleaseErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ServerNpmReleaseError" + ] + }, + "reason": { + "type": "string" + } + }, + "required": [ + "_tag", + "reason" + ], + "additionalProperties": true + }, + "ServerPrepareAcpRegistryAgentPayload": { + "type": "object", + "properties": { + "agentId": { + "type": "string" + } + }, + "required": [ + "agentId" + ], + "additionalProperties": true + }, + "ServerPrepareAcpRegistryAgentSuccess": { + "type": "object", + "properties": { + "agentId": { + "type": "string" + }, + "version": { + "type": "string" + }, + "distribution": { + "type": "string", + "enum": [ + "binary", + "npx", + "uvx" + ] + }, + "prepared": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "agentId", + "version", + "distribution", + "prepared" + ], + "additionalProperties": true + }, + "ServerPrepareTriageError": { + "anyOf": [ + { + "$ref": "#/$defs/ServerPrepareTriageErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ServerPrepareTriageErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ServerPrepareTriageError" + ] + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag" + ], + "additionalProperties": true + }, + "ServerPrepareTriagePayload": { + "type": "object", + "properties": { + "client": { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "deviceType": { + "type": "string", + "enum": [ + "desktop", + "mobile", + "tablet", + "bot", + "unknown" + ] + }, + "os": { + "type": "string" + }, + "osMajorVersion": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "deviceModel": { + "type": "string" + }, + "surface": { + "type": "string", + "enum": [ + "web", + "desktop", + "mobile", + "cli" + ] + }, + "webDeployment": { + "type": "string", + "enum": [ + "hosted", + "server" + ] + }, + "browser": { + "type": "string" + }, + "appVersion": { + "type": "string" + } + }, + "additionalProperties": true + } + }, + "required": [ + "client" + ], + "additionalProperties": true + }, + "ServerPrepareTriageSuccess": { + "type": "object", + "properties": { + "prompt": { + "type": "string" + } + }, + "required": [ + "prompt" + ], + "additionalProperties": true + }, + "ServerProviderUpdateErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ServerProviderUpdateError" + ] + }, + "provider": { + "type": "string" + }, + "reason": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "provider", + "reason" + ], + "additionalProperties": true + }, + "ServerRefreshProvidersError": { + "anyOf": [ + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + }, + { + "$ref": "#/$defs/ProviderSetupErrorEncoded" + } + ] + }, + "ServerRefreshProvidersPayload": { + "type": "object", + "properties": { + "instanceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "fresh": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "refreshModels": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "reloadSecrets": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "workspaceSnapshotsOnDemand": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "usageLimitsCommand": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + "ServerRefreshProvidersSuccess": { + "type": "object", + "properties": { + "providers": { + "$ref": "#/$defs/Providers" + } + }, + "required": [ + "providers" + ], + "additionalProperties": true + }, + "ServerRemoveKeybindingError": { + "anyOf": [ + { + "$ref": "#/$defs/KeybindingsConfigParseErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ServerRemoveKeybindingPayload": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "command": { + "$ref": "#/$defs/Command" + }, + "when": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "key", + "command" + ], + "additionalProperties": true + }, + "ServerRemoveKeybindingSuccess": { + "type": "object", + "properties": { + "keybindings": { + "$ref": "#/$defs/Keybindings" + }, + "issues": { + "$ref": "#/$defs/Issues" + } + }, + "required": [ + "keybindings", + "issues" + ], + "additionalProperties": true + }, + "ServerReportClientActivityPayload": { + "type": "object", + "properties": { + "environmentId": { + "type": "string" + }, + "clientId": { + "type": "string" + }, + "clientKind": { + "type": "string", + "enum": [ + "web", + "desktop-renderer", + "mobile", + "unknown" + ] + }, + "visible": { + "type": "boolean" + }, + "focused": { + "type": "boolean" + }, + "recentlyInteracted": { + "type": "boolean" + }, + "appState": { + "type": "string", + "enum": [ + "active", + "inactive", + "background", + "unknown" + ] + }, + "lowPowerMode": { + "type": "string", + "enum": [ + "true", + "false", + "unknown" + ] + }, + "scopes": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "server-config" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "provider-status" + ] + }, + "instanceId": { + "type": "string" + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "vcs-status" + ] + }, + "cwd": { + "type": "string" + } + }, + "required": [ + "type", + "cwd" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "git-refs" + ] + }, + "cwd": { + "type": "string" + } + }, + "required": [ + "type", + "cwd" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "diagnostics" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "thread" + ] + }, + "threadId": { + "type": "string" + } + }, + "required": [ + "type", + "threadId" + ], + "additionalProperties": true + } + ] + } + }, + "ttlMs": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "observedAt": { + "type": "string" + } + }, + "required": [ + "clientId", + "clientKind", + "visible", + "focused", + "recentlyInteracted", + "scopes", + "observedAt" + ], + "additionalProperties": true + }, + "ServerRetryResourceTelemetrySuccess": { + "type": "object", + "properties": { + "accepted": { + "type": "boolean" + }, + "snapshot": { + "$ref": "#/$defs/Snapshot2" + } + }, + "required": [ + "accepted", + "snapshot" + ], + "additionalProperties": true + }, + "ServerRunStorageCleanupError": { + "anyOf": [ + { + "$ref": "#/$defs/ServerSettingsErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ServerSearchAcpRegistryPayload": { + "type": "object", + "properties": { + "query": { + "type": "string" + }, + "limit": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 512 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "query" + ], + "additionalProperties": true + }, + "ServerSearchAcpRegistrySuccess": { + "type": "object", + "properties": { + "agents": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "description": { + "type": "string", + "maxLength": 1024 + }, + "authors": { + "type": "array", + "items": { + "type": "string", + "maxLength": 256 + }, + "maxItems": 16 + }, + "license": { + "anyOf": [ + { + "type": "string", + "maxLength": 128 + }, + { + "type": "null" + } + ] + }, + "website": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048 + }, + { + "type": "null" + } + ] + }, + "repository": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048 + }, + { + "type": "null" + } + ] + }, + "icon": { + "anyOf": [ + { + "type": "string", + "maxLength": 2048 + }, + { + "type": "null" + } + ] + }, + "distribution": { + "type": "string", + "enum": [ + "binary", + "npx", + "uvx" + ] + }, + "integrity": { + "type": "string", + "enum": [ + "sha256", + "registry" + ] + } + }, + "required": [ + "id", + "name", + "version", + "description", + "authors", + "license", + "website", + "repository", + "icon", + "distribution", + "integrity" + ], + "additionalProperties": true + }, + "maxItems": 512 + } + }, + "required": [ + "agents" + ], + "additionalProperties": true + }, + "ServerSelfUpdateErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ServerSelfUpdateError" + ] + }, + "reason": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "reason" + ], + "additionalProperties": true + }, + "ServerSetAcpRegistryProviderPayload": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "providerId": { + "type": "string" + }, + "apiType": { + "type": "string" + }, + "baseUrl": { + "type": "string", + "maxLength": 2048 + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 8192 + } + } + }, + "required": [ + "instanceId", + "projectId", + "providerId", + "apiType", + "baseUrl" + ], + "additionalProperties": true + }, + "ServerSetAcpRegistryProviderSuccess": { + "type": "object", + "properties": { + "configured": { + "type": "boolean", + "enum": [ + true + ] + } + }, + "required": [ + "configured" + ], + "additionalProperties": true + }, + "ServerSettingsErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "ServerSettingsError" + ] + }, + "settingsPath": { + "type": "string" + }, + "operation": { + "type": "string", + "enum": [ + "normalize", + "check-exists", + "create-provider-instance", + "read-file", + "read-provider-history", + "read-project-settings", + "read-secret", + "remove-secret", + "remove-stale-secret", + "write-secret", + "write-secret-provider", + "write-file", + "prepare-directory", + "write-managed-setting", + "use-disallowed-provider" + ] + }, + "providerInstanceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "environmentVariable": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "managedSetting": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "managedBy": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "providerLabel": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "settingsPath", + "operation" + ], + "additionalProperties": true + }, + "ServerSignalProcessPayload": { + "type": "object", + "properties": { + "pid": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "startTimeMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "signal": { + "type": "string", + "enum": [ + "SIGINT", + "SIGKILL" + ] + } + }, + "required": [ + "pid", + "startTimeMs", + "signal" + ], + "additionalProperties": true + }, + "ServerSignalProcessSuccess": { + "type": "object", + "properties": { + "pid": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "signal": { + "type": "string", + "enum": [ + "SIGINT", + "SIGKILL" + ] + }, + "signaled": { + "type": "boolean" + }, + "message": { + "$ref": "#/$defs/LastError" + } + }, + "required": [ + "pid", + "signal", + "signaled", + "message" + ], + "additionalProperties": true + }, + "ServerUninstallAcpRegistryManagedBinarySuccess": { + "type": "object", + "properties": { + "agentId": { + "type": "string" + }, + "removed": { + "type": "boolean" + } + }, + "required": [ + "agentId", + "removed" + ], + "additionalProperties": true + }, + "ServerUpdateProviderError": { + "anyOf": [ + { + "$ref": "#/$defs/ServerProviderUpdateErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ServerUpdateProviderPayload": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "targetVersion": { + "type": "string" + }, + "instanceId": { + "type": "string" + } + }, + "required": [ + "provider" + ], + "additionalProperties": true + }, + "ServerUpdateServerPayload": { + "type": "object", + "properties": { + "targetVersion": { + "type": "string" + }, + "channel": { + "type": "string", + "enum": [ + "latest", + "nightly" + ] + }, + "targetDatabaseMigration": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "required": [ + "targetVersion" + ], + "additionalProperties": true + }, + "ServerUpdateServerWithProgressSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "progress" + ] + }, + "stage": { + "type": "string", + "enum": [ + "downloading", + "installing" + ] + } + }, + "required": [ + "type", + "stage" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "complete" + ] + }, + "result": { + "$ref": "#/$defs/ServerCommitDesktopUpdateSuccess" + } + }, + "required": [ + "type", + "result" + ], + "additionalProperties": true + } + ] + }, + "ServerUpdateSettingsPayload": { + "type": "object", + "properties": { + "patch": { + "type": "object", + "properties": { + "setModelFavorites": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string" + }, + "favorite": { + "type": "boolean" + } + }, + "required": [ + "provider", + "model", + "favorite" + ], + "additionalProperties": true + } + }, + "setModelsHidden": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string" + }, + "hidden": { + "type": "boolean" + } + }, + "required": [ + "provider", + "model", + "hidden" + ], + "additionalProperties": true + } + }, + "setProviderModelOrder": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "modelOrder": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "provider", + "modelOrder" + ], + "additionalProperties": true + }, + "favorites": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string" + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": true + } + }, + "providerModelPreferences": { + "$ref": "#/$defs/ProviderModelPreferences" + }, + "migrateModelPreferences": { + "type": "object", + "properties": { + "favorites": { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string" + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": true + } + }, + "providerModelPreferences": { + "$ref": "#/$defs/ProviderModelPreferences" + } + }, + "additionalProperties": true + }, + "worktreeCleanup": { + "anyOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "off" + ] + } + }, + "required": [ + "mode" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "custom" + ] + }, + "rules": { + "type": "object", + "properties": { + "worktreeKeepWhen": { + "type": "string", + "enum": [ + "any-local-files", + "uncommitted-changes", + "tracked-changes" + ] + }, + "worktreeAfterDays": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 3650 + }, + { + "type": "null" + } + ] + }, + "worktreeSettledAfterDays": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 3650 + }, + { + "type": "null" + } + ] + }, + "worktreeOnMerge": { + "type": "boolean" + }, + "worktreeOnDelete": { + "type": "boolean" + }, + "worktreeUnchanged": { + "type": "boolean" + } + }, + "additionalProperties": true + } + }, + "required": [ + "mode", + "rules" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "storageCleanup": { + "type": "object", + "properties": { + "worktreeKeepWhen": { + "type": "string", + "enum": [ + "any-local-files", + "uncommitted-changes", + "tracked-changes" + ] + }, + "worktreeAfterDays": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 3650 + }, + { + "type": "null" + } + ] + }, + "worktreeSettledAfterDays": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 3650 + }, + { + "type": "null" + } + ] + }, + "worktreeOnMerge": { + "type": "boolean" + }, + "worktreeOnDelete": { + "type": "boolean" + }, + "worktreeUnchanged": { + "type": "boolean" + }, + "browserArtifactsAfterDays": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 3650 + }, + { + "type": "null" + } + ] + }, + "logsAfterDays": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 3650 + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + "worktreesDirectory": { + "type": "string" + }, + "responseStreamingMode": { + "type": "string", + "enum": [ + "turn", + "paragraph" + ] + }, + "enableProviderUpdateChecks": { + "type": "boolean" + }, + "continueThreadsAfterServerUpdate": { + "type": "boolean" + }, + "enableAgentBrowserAccess": { + "type": "boolean" + }, + "projectAgentBrowserAccessOverrides": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "defaultAutoPull": { + "type": "boolean" + }, + "defaultProjectScripts": { + "$ref": "#/$defs/DefaultProjectScripts" + }, + "projectScriptOverrides": { + "$ref": "#/$defs/ProjectScriptOverrides" + }, + "projectAutoPullOverrides": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "defaultModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "defaultRuntimeMode": { + "type": "string", + "enum": [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + "fallbackModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "projectSettingsOverrides": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "worktreeCleanup": { + "anyOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "off" + ] + } + }, + "required": [ + "mode" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/WorktreeCleanup" + }, + { + "type": "null" + } + ] + }, + "defaultModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "defaultRuntimeMode": { + "type": "string", + "enum": [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + "defaultThreadEnvMode": { + "type": "string", + "enum": [ + "local", + "worktree" + ] + }, + "newWorktreesStartFromOrigin": { + "type": "boolean" + }, + "worktreeSubmodules": {}, + "defaultAutoPull": { + "type": "boolean" + }, + "defaultProjectScripts": { + "$ref": "#/$defs/DefaultProjectScripts" + }, + "enableAgentBrowserAccess": { + "type": "boolean" + }, + "enableAgentDeviceAccess": { + "type": "boolean" + }, + "textGenerationModelSelection": { + "$ref": "#/$defs/ModelSelection" + }, + "threadTitleInstructions": { + "type": "string" + }, + "sourceControlWriterModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "reviewGuideModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "reviewGuideInstructions": { + "type": "string" + }, + "sourceControlWritingStyle": { + "$ref": "#/$defs/SourceControlWritingStyle2" + }, + "removeAgentCreditsOnMerge": { + "type": "boolean" + }, + "branchNamingMode": { + "type": "string", + "enum": [ + "static", + "semantic", + "custom" + ] + }, + "branchNamePrefix": { + "type": "string" + }, + "branchNameInstructions": { + "type": "string" + }, + "pullRequestMergeMethod": { + "anyOf": [ + { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + }, + { + "type": "null" + } + ] + }, + "sidebarAutoSettleOnMerge": { + "type": "boolean" + }, + "sidebarAutoSettleAfterDays": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "sidebarAutoSettleKeepPullRequests": { + "type": "boolean" + }, + "continueThreadsAfterServerUpdate": { + "type": "boolean" + }, + "responseStreamingMode": { + "type": "string", + "enum": [ + "turn", + "paragraph" + ] + }, + "providerInstances": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "additionalProperties": true + } + } + }, + "allOf": [ + { + "type": "object", + "additionalProperties": {} + } + ] + }, + { + "type": "null" + } + ] + } + }, + "enableAgentDeviceAccess": { + "type": "boolean" + }, + "enableDeviceSupport": { + "type": "boolean" + }, + "deviceOnboardingCompleted": { + "type": "boolean" + }, + "deviceHosts": { + "$ref": "#/$defs/DeviceHosts" + }, + "easDeviceHosts": { + "$ref": "#/$defs/EasDeviceHosts" + }, + "simlockDeviceHosts": { + "$ref": "#/$defs/SimlockDeviceHosts" + }, + "simlockDeviceHostsEnabled": { + "type": "boolean" + }, + "sidebarAutoSettleAfterDays": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "compactIdleThreads": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "onReturn", + "beforeCacheExpires", + "off" + ] + } + ] + }, + "idleCompaction": { + "type": "string", + "enum": [ + "onReturn", + "beforeCacheExpires", + "off" + ] + }, + "sidebarAutoSettleKeepPullRequests": { + "type": "boolean" + }, + "sidebarAutoSettleOnMerge": { + "type": "boolean" + }, + "autoResumeLimitedThreads": { + "type": "boolean" + }, + "snoozeLimitedThreads": { + "type": "boolean" + }, + "snoozePresets": { + "$ref": "#/$defs/SnoozePresets" + }, + "hiddenThreadTags": { + "type": "array", + "items": { + "type": "string" + } + }, + "hideTaggedThreads": { + "type": "boolean" + }, + "guestSharingEnabled": { + "type": "boolean" + }, + "linkedEnvironmentsEnabled": { + "type": "boolean" + }, + "backgroundActivity": { + "type": "object", + "properties": { + "schemaVersion": { + "type": "number", + "enum": [ + 1 + ] + }, + "profile": { + "type": "string", + "enum": [ + "balanced", + "performance", + "battery-saver", + "custom" + ] + }, + "baseProfile": { + "type": "string", + "enum": [ + "balanced", + "performance", + "battery-saver" + ] + }, + "overrides": { + "$ref": "#/$defs/Overrides" + } + }, + "additionalProperties": true + }, + "automaticGitFetchInterval": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "providerHealthRefreshInterval": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "backgroundActivityProfile": { + "type": "string", + "enum": [ + "balanced", + "performance", + "battery-saver" + ] + }, + "environmentIcon": { + "anyOf": [ + { + "type": "string", + "enum": [ + "server", + "cloud", + "linux", + "desktop", + "laptop", + "mac-mini", + "mac-studio" + ] + }, + { + "type": "null" + } + ] + }, + "defaultThreadEnvMode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "local", + "worktree" + ] + }, + { + "type": "null" + } + ] + }, + "newWorktreesStartFromOrigin": { + "type": "boolean" + }, + "worktreeSubmodules": { + "anyOf": [ + { + "type": "string", + "enum": [ + "recursive", + "top-level", + "none" + ] + }, + { + "type": "null" + } + ] + }, + "addProjectBaseDirectory": { + "type": "string" + }, + "textGenerationModelSelection": { + "type": "object", + "properties": { + "instanceId": { + "type": "string" + }, + "model": { + "type": "string" + }, + "options": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "boolean" + } + ] + } + }, + "required": [ + "id", + "value" + ], + "additionalProperties": true + } + }, + { + "type": "object" + } + ] + } + }, + "additionalProperties": true + }, + "threadTitleInstructions": { + "type": "string" + }, + "branchNamingMode": { + "type": "string", + "enum": [ + "static", + "semantic", + "custom" + ] + }, + "branchNamePrefix": { + "type": "string" + }, + "branchNameInstructions": { + "type": "string" + }, + "removeAgentCreditsOnMerge": { + "type": "boolean" + }, + "sourceControlWritingStyle": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "repo_conventions", + "conventional_commits", + "custom" + ] + }, + "customInstructions": { + "type": "string" + }, + "followChangeRequestTemplates": { + "type": "boolean" + } + }, + "additionalProperties": true + }, + "sourceControlWriterModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "reviewGuidesEnabled": { + "type": "boolean" + }, + "reviewGuideModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "reviewGuideInstructions": { + "type": "string" + }, + "pullRequestMergeMethod": { + "anyOf": [ + { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + }, + { + "type": "null" + } + ] + }, + "observability": { + "type": "object", + "properties": { + "otlpTracesUrl": { + "type": "string" + }, + "otlpMetricsUrl": { + "type": "string" + }, + "otlpLogsUrl": { + "type": "string" + } + }, + "additionalProperties": true + }, + "sourceControlHosts": { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + "providerInstances": { + "$ref": "#/$defs/ProviderInstances" + }, + "usageLimitSources": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/UsageLimitSources2" + }, + { + "type": "null" + } + ] + } + }, + "secretProviders": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "label": { + "type": "string" + }, + "config": {} + }, + "required": [ + "kind", + "label" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "cursorKeychainUsageEnabled": { + "type": "boolean" + }, + "usagePriceOverrides": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "$ref": "#/$defs/UsagePriceOverrides2" + }, + { + "type": "null" + } + ] + } + }, + "usageModelAliases": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + } + }, + "additionalProperties": true + }, + "providerInstanceMutation": { + "anyOf": [ + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": [ + "create" + ] + }, + "instanceId": { + "type": "string" + }, + "instance": { + "$ref": "#/$defs/ProviderInstances2" + } + }, + "required": [ + "operation", + "instanceId", + "instance" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": [ + "upsert" + ] + }, + "instanceId": { + "type": "string" + }, + "instance": { + "$ref": "#/$defs/ProviderInstances2" + } + }, + "required": [ + "operation", + "instanceId", + "instance" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": [ + "remove" + ] + }, + "instanceId": { + "type": "string" + } + }, + "required": [ + "operation", + "instanceId" + ], + "additionalProperties": true + } + ] + } + }, + "required": [ + "patch" + ], + "additionalProperties": true + }, + "ServerUpsertKeybindingPayload": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "command": { + "$ref": "#/$defs/Command" + }, + "when": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "replace": { + "anyOf": [ + { + "$ref": "#/$defs/ServerRemoveKeybindingPayload" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "key", + "command" + ], + "additionalProperties": true + }, + "SessionsEntry": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "hostId": { + "type": "string" + }, + "deviceId": { + "type": "string" + }, + "platform": { + "type": "string", + "enum": [ + "ios", + "android" + ] + }, + "openedAt": { + "type": "string" + } + }, + "required": [ + "threadId", + "hostId", + "deviceId", + "platform", + "openedAt" + ], + "additionalProperties": true + }, + "Settings2": { + "type": "object", + "properties": { + "favorites": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "model": { + "type": "string" + } + }, + "required": [ + "provider", + "model" + ], + "additionalProperties": true + } + }, + { + "type": "null" + } + ] + }, + "providerModelPreferences": { + "anyOf": [ + { + "$ref": "#/$defs/ProviderModelPreferences" + }, + { + "type": "null" + } + ] + }, + "worktreeCleanup": { + "anyOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "off" + ] + } + }, + "required": [ + "mode" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/WorktreeCleanup" + }, + { + "type": "null" + } + ] + }, + "storageCleanup": { + "anyOf": [ + { + "type": "object", + "properties": { + "worktreeKeepWhen": { + "anyOf": [ + { + "type": "string", + "enum": [ + "any-local-files", + "uncommitted-changes", + "tracked-changes" + ] + }, + { + "type": "null" + } + ] + }, + "worktreeAfterDays": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 3650 + }, + { + "type": "null" + } + ] + }, + "worktreeSettledAfterDays": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 3650 + }, + { + "type": "null" + } + ] + }, + "worktreeOnMerge": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "worktreeOnDelete": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "worktreeUnchanged": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "browserArtifactsAfterDays": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 3650 + }, + { + "type": "null" + } + ] + }, + "logsAfterDays": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 3650 + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "worktreesDirectory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "previousWorktreesDirectories": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "responseStreamingMode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "turn", + "paragraph" + ] + }, + { + "type": "null" + } + ] + }, + "enableProviderUpdateChecks": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "continueThreadsAfterServerUpdate": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "enableAgentBrowserAccess": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "projectAgentBrowserAccessOverrides": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + { + "type": "null" + } + ] + }, + "defaultAutoPull": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "defaultProjectScripts": { + "anyOf": [ + { + "$ref": "#/$defs/DefaultProjectScripts" + }, + { + "type": "null" + } + ] + }, + "projectScriptOverrides": { + "anyOf": [ + { + "$ref": "#/$defs/ProjectScriptOverrides" + }, + { + "type": "null" + } + ] + }, + "projectAutoPullOverrides": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "boolean" + } + }, + { + "type": "null" + } + ] + }, + "defaultModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "fallbackModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "defaultRuntimeMode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + { + "type": "null" + } + ] + }, + "projectSettingsOverrides": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "worktreeCleanup": { + "anyOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "off" + ] + } + }, + "required": [ + "mode" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/WorktreeCleanup" + }, + { + "type": "null" + } + ] + }, + "defaultModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "defaultRuntimeMode": { + "type": "string", + "enum": [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + "defaultThreadEnvMode": { + "type": "string", + "enum": [ + "local", + "worktree" + ] + }, + "newWorktreesStartFromOrigin": { + "type": "boolean" + }, + "worktreeSubmodules": {}, + "defaultAutoPull": { + "type": "boolean" + }, + "defaultProjectScripts": { + "$ref": "#/$defs/DefaultProjectScripts" + }, + "enableAgentBrowserAccess": { + "type": "boolean" + }, + "enableAgentDeviceAccess": { + "type": "boolean" + }, + "textGenerationModelSelection": { + "$ref": "#/$defs/ModelSelection" + }, + "threadTitleInstructions": { + "type": "string" + }, + "sourceControlWriterModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "reviewGuideModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "reviewGuideInstructions": { + "type": "string" + }, + "sourceControlWritingStyle": { + "anyOf": [ + { + "$ref": "#/$defs/SourceControlWritingStyle2" + }, + { + "type": "string" + } + ] + }, + "removeAgentCreditsOnMerge": { + "type": "boolean" + }, + "branchNamingMode": { + "type": "string", + "enum": [ + "static", + "semantic", + "custom" + ] + }, + "branchNamePrefix": { + "type": "string" + }, + "branchNameInstructions": { + "type": "string" + }, + "pullRequestMergeMethod": { + "anyOf": [ + { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + }, + { + "type": "null" + } + ] + }, + "sidebarAutoSettleOnMerge": { + "type": "boolean" + }, + "sidebarAutoSettleAfterDays": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "sidebarAutoSettleKeepPullRequests": { + "type": "boolean" + }, + "continueThreadsAfterServerUpdate": { + "type": "boolean" + }, + "responseStreamingMode": { + "type": "string", + "enum": [ + "turn", + "paragraph" + ] + }, + "providerInstances": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + } + }, + "additionalProperties": true + } + } + }, + "allOf": [ + { + "type": "object", + "additionalProperties": {} + } + ] + } + }, + { + "type": "null" + } + ] + }, + "projectSettingsFolded": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "enableAgentDeviceAccess": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "enableDeviceSupport": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "deviceOnboardingCompleted": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "deviceHosts": { + "anyOf": [ + { + "$ref": "#/$defs/DeviceHosts" + }, + { + "type": "null" + } + ] + }, + "easDeviceHosts": { + "anyOf": [ + { + "$ref": "#/$defs/EasDeviceHosts" + }, + { + "type": "null" + } + ] + }, + "simlockDeviceHosts": { + "anyOf": [ + { + "$ref": "#/$defs/SimlockDeviceHosts" + }, + { + "type": "null" + } + ] + }, + "simlockDeviceHostsEnabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "sidebarAutoSettleAfterDays": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "compactIdleThreads": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "string", + "enum": [ + "onReturn", + "beforeCacheExpires", + "off" + ] + }, + { + "type": "null" + } + ] + }, + "idleCompaction": { + "type": "string", + "enum": [ + "onReturn", + "beforeCacheExpires", + "off" + ] + }, + "snoozeLimitedThreads": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "autoResumeLimitedThreads": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "snoozePresets": { + "anyOf": [ + { + "$ref": "#/$defs/SnoozePresets" + }, + { + "type": "null" + } + ] + }, + "hiddenThreadTags": { + "anyOf": [ + { + "type": "array" + }, + { + "type": "null" + } + ] + }, + "hideTaggedThreads": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "guestSharingEnabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "linkedEnvironmentsEnabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "sidebarAutoSettleKeepPullRequests": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "sidebarAutoSettleOnMerge": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "backgroundActivity": { + "anyOf": [ + { + "type": "object", + "properties": { + "schemaVersion": { + "anyOf": [ + { + "type": "number", + "enum": [ + 1 + ] + }, + { + "type": "null" + } + ] + }, + "profile": { + "anyOf": [ + { + "type": "string", + "enum": [ + "balanced", + "performance", + "battery-saver", + "custom" + ] + }, + { + "type": "null" + } + ] + }, + "baseProfile": { + "type": "string", + "enum": [ + "balanced", + "performance", + "battery-saver" + ] + }, + "overrides": { + "anyOf": [ + { + "$ref": "#/$defs/Overrides" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "automaticGitFetchInterval": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "providerHealthRefreshInterval": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "backgroundActivityProfile": { + "anyOf": [ + { + "type": "string", + "enum": [ + "balanced", + "performance", + "battery-saver" + ] + }, + { + "type": "null" + } + ] + }, + "defaultTheme": { + "anyOf": [ + { + "type": "string", + "maxLength": 64 + }, + { + "type": "null" + } + ] + }, + "defaultThemeSetAt": { + "anyOf": [ + { + "type": "string", + "maxLength": 64 + }, + { + "type": "null" + } + ] + }, + "environmentIcon": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "defaultThreadEnvMode": {}, + "newWorktreesStartFromOrigin": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "worktreeSubmodules": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + }, + "addProjectBaseDirectory": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "textGenerationModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "threadTitleInstructions": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "branchNamingMode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "static", + "semantic", + "custom" + ] + }, + { + "type": "null" + } + ] + }, + "branchNamePrefix": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "branchNameInstructions": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "removeAgentCreditsOnMerge": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "sourceControlWritingStyle": { + "anyOf": [ + { + "$ref": "#/$defs/SourceControlWritingStyle2" + }, + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sourceControlWriterModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "reviewGuidesEnabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "reviewGuideModelSelection": { + "anyOf": [ + { + "$ref": "#/$defs/ModelSelection" + }, + { + "type": "null" + } + ] + }, + "reviewGuideInstructions": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "pullRequestMergeMethod": { + "anyOf": [ + { + "type": "string", + "enum": [ + "merge", + "squash", + "rebase" + ] + }, + { + "type": "null" + } + ] + }, + "providerInstances": { + "anyOf": [ + { + "$ref": "#/$defs/ProviderInstances" + }, + { + "type": "null" + } + ] + }, + "observability": { + "anyOf": [ + { + "type": "object", + "properties": { + "otlpTracesUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "otlpMetricsUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "otlpLogsUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "sourceControlHosts": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "object" + } + }, + { + "type": "null" + } + ] + }, + "usageLimitSources": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/UsageLimitSources2" + } + }, + { + "type": "null" + } + ] + }, + "secretProviders": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "label": { + "type": "string" + }, + "config": {} + }, + "required": [ + "kind", + "label" + ], + "additionalProperties": true + } + }, + { + "type": "null" + } + ] + }, + "cursorKeychainUsageEnabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "usagePriceOverrides": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/UsagePriceOverrides2" + } + }, + { + "type": "null" + } + ] + }, + "usageModelAliases": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "usageModelFilter": {} + }, + "additionalProperties": true + }, + "Shape": { + "type": "object", + "properties": { + "trigger": { + "type": "string", + "enum": [ + "automatic", + "manual" + ] + }, + "startedAt": { + "type": "string" + }, + "finishedAt": { + "type": "string" + }, + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "worktree", + "browser-artifacts", + "logs" + ] + }, + "outcome": { + "type": "string", + "enum": [ + "removed", + "kept", + "failed" + ] + }, + "reason": { + "type": "string" + }, + "path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "projectPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "threadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "threadTitle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "bytes": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "files": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "outcome", + "reason", + "path", + "threadId", + "threadTitle" + ], + "additionalProperties": true + } + }, + "counts": { + "type": "object", + "properties": { + "removed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "kept": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "failed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "removed", + "kept", + "failed" + ], + "additionalProperties": true + }, + "omittedCount": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "bytesFreed": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "trigger", + "startedAt", + "finishedAt", + "entries", + "counts", + "omittedCount" + ], + "additionalProperties": true + }, + "ShellOpenInEditorError": { + "anyOf": [ + { + "$ref": "#/$defs/ExternalLauncherUnknownEditorErrorEncoded" + }, + { + "$ref": "#/$defs/ExternalLauncherUnsupportedEditorErrorEncoded" + }, + { + "$ref": "#/$defs/ExternalLauncherCommandNotFoundErrorEncoded" + }, + { + "$ref": "#/$defs/ExternalLauncherUnsupportedTargetErrorEncoded" + }, + { + "$ref": "#/$defs/ExternalLauncherBrowserSpawnErrorEncoded" + }, + { + "$ref": "#/$defs/ExternalLauncherEditorSpawnErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "ShellOpenInEditorPayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "editor": { + "$ref": "#/$defs/Editor" + }, + "reveal": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "cwd", + "editor" + ], + "additionalProperties": true + }, + "SimlockDeviceHosts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "simlock" + ] + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "gatewayUrl": { + "type": "string" + }, + "agentToken": { + "type": "string" + }, + "maxLeases": { + "type": "integer", + "minimum": 1, + "maximum": 20 + }, + "maxLeaseMinutes": { + "type": "integer", + "minimum": 5, + "maximum": 240 + } + }, + "required": [ + "kind", + "id", + "label", + "gatewayUrl" + ], + "additionalProperties": true + } + }, + "SimlockHostsEntry": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "platforms": { + "$ref": "#/$defs/Platforms" + }, + "tools": { + "anyOf": [ + { + "$ref": "#/$defs/Tools" + }, + { + "type": "null" + } + ] + }, + "toolInspectionError": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hubInstalled": { + "type": "boolean" + }, + "agentDeviceInstalled": { + "type": "boolean" + }, + "kind": { + "type": "string", + "enum": [ + "simlock" + ] + } + }, + "required": [ + "id", + "label", + "platforms", + "hubInstalled", + "agentDeviceInstalled", + "kind" + ], + "additionalProperties": true + }, + "Skills2": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "path": { + "type": "string" + }, + "scope": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "enabled": { + "type": "boolean" + }, + "displayName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "shortDescription": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "userInvocationOnly": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "userInvocable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "path", + "enabled" + ], + "additionalProperties": true + } + }, + "SlashCommands": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "input": { + "anyOf": [ + { + "type": "object", + "properties": { + "hint": { + "type": "string" + } + }, + "required": [ + "hint" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name" + ], + "additionalProperties": true + } + }, + "Snapshot": { + "type": "object", + "properties": { + "state": { + "type": "string", + "enum": [ + "open", + "closed", + "merged" + ] + }, + "title": { + "type": "string" + }, + "headBranch": { + "type": "string" + }, + "baseBranch": { + "type": "string" + }, + "isDraft": { + "type": "boolean" + }, + "updatedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "syncedAt": { + "type": "string" + }, + "closedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "mergedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "author": { + "anyOf": [ + { + "$ref": "#/$defs/Author" + }, + { + "type": "null" + } + ] + }, + "additions": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "deletions": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "changedFiles": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "reviewDecision": { + "anyOf": [ + { + "type": "string", + "enum": [ + "approved", + "changes-requested", + "review-required" + ] + }, + { + "type": "null" + } + ] + }, + "checksState": { + "anyOf": [ + { + "type": "string", + "enum": [ + "passing", + "failing", + "pending" + ] + }, + { + "type": "null" + } + ] + }, + "mergeability": { + "anyOf": [ + { + "type": "string", + "enum": [ + "mergeable", + "conflicting", + "unknown" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "state", + "title", + "headBranch", + "baseBranch", + "isDraft", + "updatedAt", + "syncedAt" + ], + "additionalProperties": true + }, + "Snapshot2": { + "type": "object", + "properties": { + "readAt": { + "type": "string" + }, + "sampleIntervalMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "processes": { + "type": "array", + "items": { + "type": "object", + "properties": { + "identity": { + "$ref": "#/$defs/Identity2" + }, + "ppid": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "childPids": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + }, + "depth": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "name": { + "type": "string" + }, + "command": { + "type": "string" + }, + "status": { + "type": "string" + }, + "category": { + "type": "string", + "enum": [ + "server", + "server-child", + "provider-root", + "terminal-root", + "electron-main", + "electron-renderer", + "electron-gpu", + "electron-utility", + "resource-monitor", + "unknown-t3" + ] + }, + "electronType": { + "type": "string", + "enum": [ + "Browser", + "Tab", + "Utility", + "Zygote", + "Sandbox helper", + "GPU", + "Pepper Plugin", + "Pepper Plugin Broker", + "Unknown" + ] + }, + "electronServiceName": { + "type": "string" + }, + "cpuPercent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "cpuTimeMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "residentBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "peakResidentBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "virtualBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "ioReadBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "ioWriteBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "ioReadBytesPerSecond": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "ioWriteBytesPerSecond": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "ioSemantics": { + "type": "string", + "enum": [ + "storage", + "logical", + "all-io", + "unavailable" + ] + }, + "idleWakeupsPerSecond": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "runTimeMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "firstSeenAt": { + "type": "string" + }, + "lastSeenAt": { + "type": "string" + } + }, + "required": [ + "identity", + "ppid", + "childPids", + "depth", + "name", + "command", + "status", + "category", + "cpuPercent", + "cpuTimeMs", + "residentBytes", + "peakResidentBytes", + "virtualBytes", + "ioReadBytes", + "ioWriteBytes", + "ioReadBytesPerSecond", + "ioWriteBytesPerSecond", + "ioSemantics", + "runTimeMs", + "firstSeenAt", + "lastSeenAt" + ], + "additionalProperties": true + } + }, + "groups": { + "type": "object", + "properties": { + "backend": { + "$ref": "#/$defs/AllT3" + }, + "electron": { + "$ref": "#/$defs/AllT3" + }, + "monitor": { + "$ref": "#/$defs/AllT3" + }, + "allT3": { + "$ref": "#/$defs/AllT3" + } + }, + "required": [ + "backend", + "electron", + "monitor", + "allT3" + ], + "additionalProperties": true + }, + "power": { + "type": "object", + "properties": { + "source": { + "type": "string", + "enum": [ + "unknown", + "node-macos-shell", + "node-macos-native", + "node-linux", + "node-windows", + "electron-main" + ] + }, + "idle": { + "type": "string", + "enum": [ + "true", + "false", + "unknown" + ] + }, + "idleSeconds": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + }, + { + "type": "null" + } + ] + }, + "locked": { + "type": "string", + "enum": [ + "true", + "false", + "unknown" + ] + }, + "suspended": { + "type": "boolean" + }, + "onBattery": { + "type": "string", + "enum": [ + "true", + "false", + "unknown" + ] + }, + "lowPowerMode": { + "type": "string", + "enum": [ + "true", + "false", + "unknown" + ] + }, + "thermalState": { + "type": "string", + "enum": [ + "unknown", + "nominal", + "fair", + "serious", + "critical" + ] + }, + "stale": { + "type": "boolean" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "source", + "idle", + "idleSeconds", + "locked", + "suspended", + "onBattery", + "lowPowerMode", + "thermalState", + "stale", + "updatedAt" + ], + "additionalProperties": true + }, + "speedLimitPercent": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "Some" + ] + }, + "value": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "_tag", + "value" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "None" + ] + } + }, + "required": [ + "_tag" + ], + "additionalProperties": true + } + ] + }, + "attribution": { + "type": "object", + "properties": { + "readAt": { + "type": "string" + }, + "entries": { + "type": "array", + "items": { + "type": "object", + "properties": { + "component": { + "type": "string" + }, + "operation": { + "type": "string" + }, + "logicalReadBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "logicalWriteBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "durationMs": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "component", + "operation", + "logicalReadBytes", + "logicalWriteBytes", + "count", + "durationMs" + ], + "additionalProperties": true + } + } + }, + "required": [ + "readAt", + "entries" + ], + "additionalProperties": true + }, + "health": { + "$ref": "#/$defs/Health" + } + }, + "required": [ + "readAt", + "sampleIntervalMs", + "processes", + "groups", + "power", + "speedLimitPercent", + "attribution", + "health" + ], + "additionalProperties": true + }, + "Snapshot3": { + "type": "object", + "properties": { + "threadId": { + "type": "string", + "minLength": 1 + }, + "terminalId": { + "type": "string", + "minLength": 1 + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "worktreePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "starting", + "running", + "exited", + "error" + ] + }, + "pid": { + "anyOf": [ + { + "type": "integer", + "maximum": 9007199254740991, + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ] + }, + "history": { + "type": "string" + }, + "exitCode": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "exitSignal": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "label": { + "type": "string", + "maxLength": 128 + }, + "updatedAt": { + "type": "string" + }, + "sequence": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "cols": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + { + "type": "null" + } + ] + }, + "rows": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 500 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "threadId", + "terminalId", + "cwd", + "worktreePath", + "status", + "pid", + "history", + "exitCode", + "exitSignal", + "label", + "updatedAt" + ], + "additionalProperties": true + }, + "SnoozePresets": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "after" + ] + }, + "minutes": { + "type": "integer", + "minimum": 1, + "maximum": 43200 + } + }, + "required": [ + "kind", + "minutes" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "at" + ] + }, + "day": { + "type": "string", + "enum": [ + "today", + "tomorrow", + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday" + ] + }, + "time": { + "type": "string" + } + }, + "required": [ + "kind", + "day", + "time" + ], + "additionalProperties": true + } + ] + }, + "maxItems": 8 + }, + "Source": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "snap-shot" + ] + }, + "capturedAt": { + "type": "string" + }, + "appName": { + "type": "string" + }, + "windowTitle": { + "type": "string" + }, + "accessibleText": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "accessibility": { + "anyOf": [ + { + "type": "object", + "properties": { + "format": { + "type": "string", + "enum": [ + "flat-text" + ] + }, + "text": { + "type": "string" + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "format", + "text", + "truncated" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "format": { + "type": "string", + "enum": [ + "element-tree" + ] + }, + "coordinateSpace": { + "type": "string", + "enum": [ + "captured-image" + ] + }, + "imageSize": { + "$ref": "#/$defs/ImageSize" + }, + "truncated": { + "type": "boolean" + }, + "root": { + "$ref": "#/$defs/Objects_" + } + }, + "required": [ + "format", + "coordinateSpace", + "imageSize", + "truncated", + "root" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "appIdentifier": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "appIconDataUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "capturedAt", + "appName", + "windowTitle" + ], + "additionalProperties": true + }, + "Source2": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "delegated_task" + ] + }, + "taskIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "childThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "taskIds" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "background_task" + ] + }, + "work": { + "type": "string", + "enum": [ + "subagent" + ] + }, + "childThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "work" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "background_command" + ] + } + }, + "required": [ + "kind" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "subagent" + ] + }, + "childThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "command" + ] + } + }, + "required": [ + "kind" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "monitor" + ] + } + }, + "required": [ + "kind" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "background_task" + ] + } + }, + "required": [ + "kind" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "system" + ] + } + }, + "required": [ + "kind" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "kind": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + } + ] + }, + "SourceControlAppAccessPayload": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "host": { + "type": "string" + }, + "repository": { + "type": "string" + } + }, + "required": [ + "provider", + "host", + "repository" + ], + "additionalProperties": true + }, + "SourceControlAppAccessSuccess": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "covered", + "not-covered", + "signed-out" + ] + } + }, + "required": [ + "status" + ], + "additionalProperties": true + }, + "SourceControlCloneRepositoryPayload": { + "type": "object", + "properties": { + "provider": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "repository": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "remoteUrl": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "destinationPath": { + "type": "string" + }, + "protocol": { + "anyOf": [ + { + "type": "string", + "enum": [ + "auto", + "ssh", + "https" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "destinationPath" + ], + "additionalProperties": true + }, + "SourceControlCloneRepositorySuccess": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "remoteUrl": { + "type": "string" + }, + "repository": { + "anyOf": [ + { + "$ref": "#/$defs/Repository" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "cwd", + "remoteUrl", + "repository" + ], + "additionalProperties": true + }, + "SourceControlLookupRepositoryPayload": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "provider", + "repository" + ], + "additionalProperties": true + }, + "SourceControlProviderErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SourceControlProviderError" + ] + }, + "provider": { + "type": "string" + }, + "operation": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "command": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "repository": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "reference": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "detail": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "provider", + "operation", + "cwd", + "detail" + ], + "additionalProperties": true + }, + "SourceControlPublishRepositoryPayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "provider": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "visibility": { + "type": "string", + "enum": [ + "private", + "public" + ] + }, + "remoteName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "protocol": { + "anyOf": [ + { + "type": "string", + "enum": [ + "auto", + "ssh", + "https" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "cwd", + "provider", + "repository", + "visibility" + ], + "additionalProperties": true + }, + "SourceControlPublishRepositorySuccess": { + "type": "object", + "properties": { + "repository": { + "$ref": "#/$defs/Repository" + }, + "remoteName": { + "type": "string" + }, + "remoteUrl": { + "type": "string" + }, + "branch": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pushed", + "remote_added" + ] + } + }, + "required": [ + "repository", + "remoteName", + "remoteUrl", + "branch", + "status" + ], + "additionalProperties": true + }, + "SourceControlRepositoryErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SourceControlRepositoryError" + ] + }, + "provider": { + "type": "string" + }, + "operation": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "provider", + "operation", + "detail" + ], + "additionalProperties": true + }, + "SourceControlSignInCancelError": { + "anyOf": [ + { + "$ref": "#/$defs/SourceControlSignInErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "SourceControlSignInCancelPayload": { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "host": { + "type": "string" + } + }, + "required": [ + "provider", + "host" + ], + "additionalProperties": true + }, + "SourceControlSignInErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "SourceControlSignInError" + ] + }, + "provider": { + "type": "string" + }, + "host": { + "type": "string" + }, + "reason": { + "type": "string", + "enum": [ + "unsupported", + "not-started", + "cancelled", + "denied", + "expired", + "failed" + ] + }, + "detail": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "provider", + "host", + "reason", + "detail" + ], + "additionalProperties": true + }, + "SourceControlSignInStartSuccess": { + "type": "object", + "properties": { + "userCode": { + "type": "string" + }, + "verificationUri": { + "type": "string" + }, + "expiresAt": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "userCode", + "verificationUri", + "expiresAt" + ], + "additionalProperties": true + }, + "SourceControlSignInWaitSuccess": { + "type": "object", + "properties": { + "account": { + "type": "string" + } + }, + "required": [ + "account" + ], + "additionalProperties": true + }, + "SourceControlStarSuccess": { + "type": "boolean" + }, + "SourceControlWritingStyle2": { + "type": "object", + "properties": { + "mode": { + "anyOf": [ + { + "type": "string", + "enum": [ + "repo_conventions", + "conventional_commits", + "custom" + ] + }, + { + "type": "null" + } + ] + }, + "customInstructions": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "followChangeRequestTemplates": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + "SourcePoint": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "latest_stable" + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "run" + ] + }, + "runId": { + "type": "string" + } + }, + "required": [ + "type", + "runId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "checkpoint" + ] + }, + "checkpointId": { + "type": "string" + } + }, + "required": [ + "type", + "checkpointId" + ], + "additionalProperties": true + } + ] + }, + "SourceTokenUsage": { + "type": "object", + "properties": { + "usedTokens": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "maxTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "inputTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "cachedInputTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "outputTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "reasoningOutputTokens": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "promptCacheTtlMs": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "autoCompactThreshold": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "promptCacheExpiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "usedTokens", + "updatedAt" + ], + "additionalProperties": true + }, + "Stack": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "native" + ] + }, + "id": { + "type": "string" + }, + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "url": { + "type": "string" + }, + "base": { + "type": "string" + }, + "layers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "headBranch": { + "type": "string" + }, + "state": { + "type": "string", + "enum": [ + "open", + "closed", + "merged" + ] + } + }, + "required": [ + "number", + "headBranch", + "state" + ], + "additionalProperties": true + } + } + }, + "required": [ + "kind", + "id", + "number", + "url", + "base", + "layers" + ], + "additionalProperties": true + }, + "Stack2": { + "type": "object", + "properties": { + "number": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "position": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "size": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "base": { + "type": "string" + } + }, + "required": [ + "number", + "position", + "size", + "base" + ], + "additionalProperties": true + }, + "Steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "text": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "pending", + "running", + "completed" + ] + }, + "durationAnchorAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "durationMs": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "text", + "status" + ], + "additionalProperties": true + } + }, + "SubscribeAuthAccessError": { + "anyOf": [ + { + "$ref": "#/$defs/AuthAccessStreamErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "SubscribeAuthAccessSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "revision": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "type": { + "type": "string", + "enum": [ + "snapshot" + ] + }, + "payload": { + "type": "object", + "properties": { + "pairingLinks": { + "type": "array", + "items": { + "$ref": "#/$defs/PairingLinksEntry" + } + }, + "clientSessions": { + "type": "array", + "items": { + "$ref": "#/$defs/ClientSessionsEntry" + } + } + }, + "required": [ + "pairingLinks", + "clientSessions" + ], + "additionalProperties": true + } + }, + "required": [ + "version", + "revision", + "type", + "payload" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "revision": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "type": { + "type": "string", + "enum": [ + "pairingLinkUpserted" + ] + }, + "payload": { + "$ref": "#/$defs/PairingLinksEntry" + } + }, + "required": [ + "version", + "revision", + "type", + "payload" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "revision": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "type": { + "type": "string", + "enum": [ + "pairingLinkRemoved" + ] + }, + "payload": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "additionalProperties": true + } + }, + "required": [ + "version", + "revision", + "type", + "payload" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "revision": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "type": { + "type": "string", + "enum": [ + "clientUpserted" + ] + }, + "payload": { + "$ref": "#/$defs/ClientSessionsEntry" + } + }, + "required": [ + "version", + "revision", + "type", + "payload" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "revision": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "type": { + "type": "string", + "enum": [ + "clientRemoved" + ] + }, + "payload": { + "type": "object", + "properties": { + "sessionId": { + "type": "string" + } + }, + "required": [ + "sessionId" + ], + "additionalProperties": true + } + }, + "required": [ + "version", + "revision", + "type", + "payload" + ], + "additionalProperties": true + } + ] + }, + "SubscribeDiscoveredLocalServersPayload": { + "type": "object", + "properties": { + "configuredUrls": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 32 + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + "SubscribeDiscoveredLocalServersSuccess": { + "type": "object", + "properties": { + "servers": { + "type": "array", + "items": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "exclusiveMinimum": 0, + "exclusiveMaximum": 65536 + }, + "url": { + "type": "string" + }, + "processName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "pid": { + "anyOf": [ + { + "type": "integer", + "maximum": 9007199254740991, + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ] + }, + "terminal": { + "anyOf": [ + { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "terminalId": { + "type": "string" + } + }, + "required": [ + "threadId", + "terminalId" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "agent": { + "anyOf": [ + { + "type": "object", + "properties": { + "threadId": { + "type": "string" + } + }, + "required": [ + "threadId" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "host", + "port", + "url", + "processName", + "pid", + "terminal" + ], + "additionalProperties": true + } + }, + "scannedAt": { + "type": "string" + }, + "configuredUrlProbing": { + "anyOf": [ + { + "type": "boolean", + "enum": [ + true + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "servers", + "scannedAt" + ], + "additionalProperties": true + }, + "SubscribePreviewEventsSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "tabId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "serverEpoch": { + "type": "string" + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "type": { + "type": "string", + "enum": [ + "opened" + ] + }, + "snapshot": { + "$ref": "#/$defs/PreviewAdjustSuccess" + } + }, + "required": [ + "threadId", + "tabId", + "createdAt", + "serverEpoch", + "revision", + "type", + "snapshot" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "tabId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "serverEpoch": { + "type": "string" + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "type": { + "type": "string", + "enum": [ + "navigated" + ] + }, + "snapshot": { + "$ref": "#/$defs/PreviewAdjustSuccess" + } + }, + "required": [ + "threadId", + "tabId", + "createdAt", + "serverEpoch", + "revision", + "type", + "snapshot" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "tabId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "serverEpoch": { + "type": "string" + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "type": { + "type": "string", + "enum": [ + "resized" + ] + }, + "snapshot": { + "$ref": "#/$defs/PreviewAdjustSuccess" + }, + "request": { + "anyOf": [ + { + "type": "object", + "properties": { + "hardReload": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "clear": { + "anyOf": [ + { + "type": "string", + "enum": [ + "cookies", + "cache" + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "threadId", + "tabId", + "createdAt", + "serverEpoch", + "revision", + "type", + "snapshot" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "tabId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "serverEpoch": { + "type": "string" + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "type": { + "type": "string", + "enum": [ + "failed" + ] + }, + "url": { + "type": "string" + }, + "title": { + "type": "string", + "maxLength": 512 + }, + "code": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + "description": { + "type": "string" + }, + "download": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "fileName": { + "type": "string" + } + }, + "required": [ + "id", + "fileName" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "threadId", + "tabId", + "createdAt", + "serverEpoch", + "revision", + "type", + "url", + "title", + "code", + "description" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "tabId": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "serverEpoch": { + "type": "string" + }, + "revision": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "type": { + "type": "string", + "enum": [ + "closed" + ] + } + }, + "required": [ + "threadId", + "tabId", + "createdAt", + "serverEpoch", + "revision", + "type" + ], + "additionalProperties": true + } + ] + }, + "SubscribeProjectClonesSuccess": { + "type": "array", + "items": { + "type": "object", + "properties": { + "projectId": { + "type": "string" + }, + "remoteUrl": { + "type": "string" + }, + "destinationPath": { + "type": "string" + }, + "repository": { + "anyOf": [ + { + "$ref": "#/$defs/Repository" + }, + { + "type": "null" + } + ] + }, + "phase": { + "type": "string", + "enum": [ + "running", + "done", + "failed", + "cancelled" + ] + }, + "stage": { + "type": "string", + "enum": [ + "connecting", + "counting", + "receiving", + "resolving", + "checkout" + ] + }, + "percent": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + { + "type": "null" + } + ] + }, + "detail": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ] + }, + "stalled": { + "type": "boolean" + }, + "error": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "type": "string" + }, + "endedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "projectId", + "remoteUrl", + "destinationPath", + "repository", + "phase", + "stage", + "percent", + "detail", + "error", + "startedAt", + "endedAt", + "sequence" + ], + "additionalProperties": true + } + }, + "SubscribeServerConfigPayload": { + "type": "object", + "properties": { + "environmentThemes": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "usageLimitSources": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "usageLimitsCommand": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "workspaceSnapshotsOnDemand": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": true + }, + "SubscribeServerConfigSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "type": { + "type": "string", + "enum": [ + "snapshot" + ] + }, + "config": { + "$ref": "#/$defs/ServerGetConfigSuccess" + } + }, + "required": [ + "version", + "type", + "config" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "type": { + "type": "string", + "enum": [ + "keybindingsUpdated" + ] + }, + "payload": { + "$ref": "#/$defs/ServerRemoveKeybindingSuccess" + } + }, + "required": [ + "version", + "type", + "payload" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "type": { + "type": "string", + "enum": [ + "providerStatuses" + ] + }, + "payload": { + "$ref": "#/$defs/ServerRefreshProvidersSuccess" + } + }, + "required": [ + "version", + "type", + "payload" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "type": { + "type": "string", + "enum": [ + "settingsUpdated" + ] + }, + "payload": { + "type": "object", + "properties": { + "settings": { + "$ref": "#/$defs/Settings2" + } + }, + "required": [ + "settings" + ], + "additionalProperties": true + } + }, + "required": [ + "version", + "type", + "payload" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "type": { + "type": "string", + "enum": [ + "environmentThemesUpdated" + ] + }, + "payload": { + "type": "object", + "properties": { + "themes": { + "$ref": "#/$defs/EnvironmentThemes" + } + }, + "required": [ + "themes" + ], + "additionalProperties": true + } + }, + "required": [ + "version", + "type", + "payload" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "type": { + "type": "string", + "enum": [ + "usageLimitSourcesUpdated" + ] + }, + "payload": { + "type": "object", + "properties": { + "sources": { + "$ref": "#/$defs/UsageLimitSources3" + } + }, + "required": [ + "sources" + ], + "additionalProperties": true + } + }, + "required": [ + "version", + "type", + "payload" + ], + "additionalProperties": true + } + ] + }, + "SubscribeServerLifecycleSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "sequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "type": { + "type": "string", + "enum": [ + "welcome" + ] + }, + "payload": { + "type": "object", + "properties": { + "environment": { + "$ref": "#/$defs/Environment" + }, + "cwd": { + "type": "string" + }, + "projectName": { + "type": "string" + }, + "bootstrapStatus": { + "anyOf": [ + { + "type": "string", + "enum": [ + "pending", + "complete" + ] + }, + { + "type": "null" + } + ] + }, + "bootstrapProjectId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "bootstrapThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "bootstrapProjectCreated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "bootstrapThreadCreated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "environment", + "cwd", + "projectName" + ], + "additionalProperties": true + } + }, + "required": [ + "version", + "sequence", + "type", + "payload" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "sequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "type": { + "type": "string", + "enum": [ + "ready" + ] + }, + "payload": { + "type": "object", + "properties": { + "at": { + "type": "string" + }, + "environment": { + "$ref": "#/$defs/Environment" + }, + "updateOutcome": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "fromVersion": { + "type": "string" + }, + "targetVersion": { + "type": "string" + }, + "status": { + "type": "string", + "enum": [ + "committed", + "rolled-back", + "failed" + ] + }, + "reason": { + "type": "string" + } + }, + "required": [ + "id", + "fromVersion", + "targetVersion", + "status" + ], + "additionalProperties": true + } + }, + "required": [ + "at", + "environment" + ], + "additionalProperties": true + } + }, + "required": [ + "version", + "sequence", + "type", + "payload" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [ + 1 + ] + }, + "sequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "type": { + "type": "string", + "enum": [ + "legacyThreadMigration" + ] + }, + "payload": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "running", + "complete" + ] + }, + "totalThreadCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "status", + "totalThreadCount" + ], + "additionalProperties": true + } + }, + "required": [ + "version", + "sequence", + "type", + "payload" + ], + "additionalProperties": true + } + ] + }, + "SubscribeTerminalMetadataSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "snapshot" + ] + }, + "terminals": { + "type": "array", + "items": { + "$ref": "#/$defs/Terminal" + } + } + }, + "required": [ + "type", + "terminals" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "upsert" + ] + }, + "terminal": { + "$ref": "#/$defs/Terminal" + } + }, + "required": [ + "type", + "terminal" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "remove" + ] + }, + "threadId": { + "type": "string", + "minLength": 1 + }, + "terminalId": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "type", + "threadId", + "terminalId" + ], + "additionalProperties": true + } + ] + }, + "SubscribeVcsStatusPayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "includeRemote": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "cwd" + ], + "additionalProperties": true + }, + "SubscribeVcsStatusSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "snapshot" + ] + }, + "local": { + "$ref": "#/$defs/Local" + }, + "remote": { + "anyOf": [ + { + "$ref": "#/$defs/Remote" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "local", + "remote" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "localUpdated" + ] + }, + "local": { + "$ref": "#/$defs/Local" + } + }, + "required": [ + "_tag", + "local" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "remoteUpdated" + ] + }, + "remote": { + "anyOf": [ + { + "$ref": "#/$defs/Remote" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "remote" + ], + "additionalProperties": true + } + ] + }, + "SubscribeWorktreeSetupSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "phase": { + "type": "string", + "enum": [ + "running", + "done", + "failed", + "cancelled" + ] + }, + "startedAt": { + "type": "string" + }, + "endedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "baseRef": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "worktreePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "setupScript": { + "anyOf": [ + { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "command": { + "type": "string" + }, + "terminalId": { + "type": "string" + } + }, + "required": [ + "name", + "command", + "terminalId" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "stages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": [ + "fetch", + "checkout", + "submodules", + "setup-script", + "agent" + ] + }, + "status": { + "type": "string", + "enum": [ + "pending", + "running", + "done", + "skipped", + "warning", + "failed" + ] + }, + "startedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "endedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "percent": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + { + "type": "null" + } + ] + }, + "detail": { + "anyOf": [ + { + "type": "string", + "maxLength": 200 + }, + { + "type": "null" + } + ] + }, + "tail": { + "type": "array", + "items": { + "type": "string", + "maxLength": 400 + } + } + }, + "required": [ + "id", + "status", + "startedAt", + "endedAt", + "percent", + "detail", + "tail" + ], + "additionalProperties": true + } + }, + "error": { + "anyOf": [ + { + "type": "string", + "maxLength": 1000 + }, + { + "type": "null" + } + ] + }, + "sequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "threadId", + "phase", + "startedAt", + "endedAt", + "branch", + "baseRef", + "worktreePath", + "setupScript", + "stages", + "error", + "sequence" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "Task": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "prompt": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "schedule": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "interval" + ] + }, + "everyMs": { + "type": "integer", + "maximum": 9007199254740991, + "exclusiveMinimum": 0 + } + }, + "required": [ + "type", + "everyMs" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/Schedule3" + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "once" + ] + }, + "runAt": { + "type": "string" + } + }, + "required": [ + "type", + "runAt" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "webhook" + ] + }, + "signature": { + "anyOf": [ + { + "type": "object", + "properties": { + "header": { + "type": "string" + }, + "encoding": { + "type": "string", + "enum": [ + "hex", + "base64" + ] + }, + "prefix": { + "type": "string", + "description": "Text before the digest, such as 'sha256='. Empty for none." + } + }, + "required": [ + "header", + "encoding", + "prefix" + ], + "additionalProperties": true, + "description": "HMAC-SHA256 check on the raw body." + }, + { + "type": "null" + } + ] + }, + "maxDeliveryAgeMinutes": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 1440, + "description": "Skip requests held offline over this many minutes. Omit to run all." + }, + { + "type": "null" + } + ] + }, + "dedupe": { + "anyOf": [ + { + "$ref": "#/$defs/Dedupe" + }, + { + "type": "null" + } + ] + }, + "filters": { + "anyOf": [ + { + "$ref": "#/$defs/Filters" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "signature" + ], + "additionalProperties": true, + "description": "Run on each webhook request." + } + ] + }, + "projectId": { + "type": "string" + }, + "threadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspaceStrategy": { + "$ref": "#/$defs/WorkspaceStrategy3" + }, + "modelSelection": { + "$ref": "#/$defs/ModelSelection" + }, + "runtimeMode": { + "type": "string", + "enum": [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + "interactionMode": { + "type": "string", + "enum": [ + "default", + "plan" + ] + }, + "createdBy": { + "type": "string", + "enum": [ + "user", + "agent", + "system" + ] + }, + "creationSource": { + "type": "string", + "enum": [ + "web", + "mobile", + "mcp", + "provider", + "server" + ] + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "nextRunAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "lastRunAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "lastRunStatus": { + "type": "string", + "enum": [ + "never", + "running", + "succeeded", + "failed" + ] + }, + "lastRunError": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "runCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "webhook": { + "anyOf": [ + { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasSecret": { + "type": "boolean" + } + }, + "required": [ + "path", + "url", + "hasSecret" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "blockedByPolicy": { + "type": "string" + } + }, + "required": [ + "id", + "title", + "prompt", + "enabled", + "schedule", + "projectId", + "threadId", + "workspaceStrategy", + "modelSelection", + "runtimeMode", + "interactionMode", + "createdBy", + "creationSource", + "createdAt", + "updatedAt", + "nextRunAt", + "lastRunAt", + "lastRunStatus", + "lastRunError", + "runCount" + ], + "additionalProperties": true + }, + "Terminal": { + "type": "object", + "properties": { + "threadId": { + "type": "string", + "minLength": 1 + }, + "terminalId": { + "type": "string", + "minLength": 1 + }, + "cwd": { + "type": "string", + "minLength": 1 + }, + "worktreePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "starting", + "running", + "exited", + "error" + ] + }, + "pid": { + "anyOf": [ + { + "type": "integer", + "maximum": 9007199254740991, + "exclusiveMinimum": 0 + }, + { + "type": "null" + } + ] + }, + "exitCode": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "exitSignal": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "hasRunningSubprocess": { + "type": "boolean" + }, + "label": { + "type": "string", + "maxLength": 128 + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "threadId", + "terminalId", + "cwd", + "worktreePath", + "status", + "pid", + "exitCode", + "exitSignal", + "hasRunningSubprocess", + "label", + "updatedAt" + ], + "additionalProperties": true + }, + "TerminalAttachError": { + "anyOf": [ + { + "$ref": "#/$defs/TerminalCwdNotFoundErrorEncoded" + }, + { + "$ref": "#/$defs/TerminalCwdNotDirectoryErrorEncoded" + }, + { + "$ref": "#/$defs/TerminalCwdStatErrorEncoded" + }, + { + "$ref": "#/$defs/TerminalHistoryErrorEncoded" + }, + { + "$ref": "#/$defs/TerminalSessionLookupErrorEncoded" + }, + { + "$ref": "#/$defs/TerminalProviderInstanceNotFoundErrorEncoded" + }, + { + "$ref": "#/$defs/TerminalProviderEnvironmentErrorEncoded" + }, + { + "$ref": "#/$defs/TerminalNotRunningErrorEncoded" + }, + { + "$ref": "#/$defs/TerminalWriteErrorEncoded" + }, + { + "$ref": "#/$defs/TerminalResizeErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "TerminalAttachPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "terminalId": { + "type": "string" + }, + "cwd": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "worktreePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cols": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + { + "type": "null" + } + ] + }, + "rows": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 500 + }, + { + "type": "null" + } + ] + }, + "env": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true, + "maxProperties": 128 + }, + { + "type": "null" + } + ] + }, + "providerInstanceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "restartIfNotRunning": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "resume": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "threadId", + "terminalId" + ], + "additionalProperties": true + }, + "TerminalAttachSuccess": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "snapshot" + ] + }, + "snapshot": { + "$ref": "#/$defs/Snapshot3" + } + }, + "required": [ + "type", + "snapshot" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "threadId": { + "type": "string", + "minLength": 1 + }, + "terminalId": { + "type": "string", + "minLength": 1 + }, + "sequence": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "output" + ] + }, + "data": { + "type": "string" + } + }, + "required": [ + "threadId", + "terminalId", + "type", + "data" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "threadId": { + "type": "string", + "minLength": 1 + }, + "terminalId": { + "type": "string", + "minLength": 1 + }, + "sequence": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "exited" + ] + }, + "exitCode": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "exitSignal": { + "anyOf": [ + { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "threadId", + "terminalId", + "type", + "exitCode", + "exitSignal" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "threadId": { + "type": "string", + "minLength": 1 + }, + "terminalId": { + "type": "string", + "minLength": 1 + }, + "sequence": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "closed" + ] + } + }, + "required": [ + "threadId", + "terminalId", + "type" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "threadId": { + "type": "string", + "minLength": 1 + }, + "terminalId": { + "type": "string", + "minLength": 1 + }, + "sequence": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "error" + ] + }, + "message": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "threadId", + "terminalId", + "type", + "message" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "threadId": { + "type": "string", + "minLength": 1 + }, + "terminalId": { + "type": "string", + "minLength": 1 + }, + "sequence": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "cleared" + ] + } + }, + "required": [ + "threadId", + "terminalId", + "type" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "threadId": { + "type": "string", + "minLength": 1 + }, + "terminalId": { + "type": "string", + "minLength": 1 + }, + "sequence": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "restarted" + ] + }, + "snapshot": { + "$ref": "#/$defs/Snapshot3" + } + }, + "required": [ + "threadId", + "terminalId", + "type", + "snapshot" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "threadId": { + "type": "string", + "minLength": 1 + }, + "terminalId": { + "type": "string", + "minLength": 1 + }, + "sequence": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "type": { + "type": "string", + "enum": [ + "activity" + ] + }, + "hasRunningSubprocess": { + "type": "boolean" + }, + "label": { + "type": "string", + "maxLength": 128 + }, + "cols": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + { + "type": "null" + } + ] + }, + "rows": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 500 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "threadId", + "terminalId", + "type", + "hasRunningSubprocess", + "label" + ], + "additionalProperties": true + } + ] + }, + "TerminalClearPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "terminalId": { + "type": "string" + } + }, + "required": [ + "threadId", + "terminalId" + ], + "additionalProperties": true + }, + "TerminalClosePayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "terminalId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deleteHistory": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "threadId" + ], + "additionalProperties": true + }, + "TerminalCwdNotDirectoryErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "TerminalCwdNotDirectoryError" + ] + }, + "cwd": { + "type": "string" + } + }, + "required": [ + "_tag", + "cwd" + ], + "additionalProperties": true + }, + "TerminalCwdNotFoundErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "TerminalCwdNotFoundError" + ] + }, + "cwd": { + "type": "string" + } + }, + "required": [ + "_tag", + "cwd" + ], + "additionalProperties": true + }, + "TerminalCwdStatErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "TerminalCwdStatError" + ] + }, + "cwd": { + "type": "string" + }, + "cause": {} + }, + "required": [ + "_tag", + "cwd", + "cause" + ], + "additionalProperties": true + }, + "TerminalHistoryErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "TerminalHistoryError" + ] + }, + "operation": { + "type": "string", + "enum": [ + "read", + "truncate", + "migrate" + ] + }, + "threadId": { + "type": "string" + }, + "terminalId": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "operation", + "threadId", + "terminalId" + ], + "additionalProperties": true + }, + "TerminalNotRunningErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "TerminalNotRunningError" + ] + }, + "threadId": { + "type": "string" + }, + "terminalId": { + "type": "string" + } + }, + "required": [ + "_tag", + "threadId", + "terminalId" + ], + "additionalProperties": true + }, + "TerminalOpenPayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "terminalId": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "worktreePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cols": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + { + "type": "null" + } + ] + }, + "rows": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 500 + }, + { + "type": "null" + } + ] + }, + "env": { + "anyOf": [ + { + "type": "object", + "additionalProperties": true, + "maxProperties": 128 + }, + { + "type": "null" + } + ] + }, + "providerInstanceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "threadId", + "terminalId", + "cwd" + ], + "additionalProperties": true + }, + "TerminalProviderEnvironmentErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "TerminalProviderEnvironmentError" + ] + }, + "providerInstanceId": { + "type": "string" + }, + "cause": {} + }, + "required": [ + "_tag", + "providerInstanceId", + "cause" + ], + "additionalProperties": true + }, + "TerminalProviderInstanceNotFoundErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "TerminalProviderInstanceNotFoundError" + ] + }, + "providerInstanceId": { + "type": "string" + } + }, + "required": [ + "_tag", + "providerInstanceId" + ], + "additionalProperties": true + }, + "TerminalResizeErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "TerminalResizeError" + ] + }, + "threadId": { + "type": "string" + }, + "terminalId": { + "type": "string" + }, + "terminalPid": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "cols": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "rows": { + "type": "integer", + "minimum": 1, + "maximum": 500 + }, + "cause": {} + }, + "required": [ + "_tag", + "threadId", + "terminalId", + "terminalPid", + "cols", + "rows", + "cause" + ], + "additionalProperties": true + }, + "TerminalResizePayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "terminalId": { + "type": "string" + }, + "cols": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + }, + "rows": { + "type": "integer", + "minimum": 1, + "maximum": 500 + } + }, + "required": [ + "threadId", + "terminalId", + "cols", + "rows" + ], + "additionalProperties": true + }, + "TerminalSessionLookupErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "TerminalSessionLookupError" + ] + }, + "threadId": { + "type": "string" + }, + "terminalId": { + "type": "string" + } + }, + "required": [ + "_tag", + "threadId", + "terminalId" + ], + "additionalProperties": true + }, + "TerminalWriteErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "TerminalWriteError" + ] + }, + "threadId": { + "type": "string" + }, + "terminalId": { + "type": "string" + }, + "terminalPid": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "cause": {} + }, + "required": [ + "_tag", + "threadId", + "terminalId", + "terminalPid", + "cause" + ], + "additionalProperties": true + }, + "TerminalWritePayload": { + "type": "object", + "properties": { + "threadId": { + "type": "string" + }, + "terminalId": { + "type": "string" + }, + "data": { + "type": "string", + "minLength": 1, + "maxLength": 65536 + } + }, + "required": [ + "threadId", + "terminalId", + "data" + ], + "additionalProperties": true + }, + "TextGenerationErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "TextGenerationError" + ] + }, + "operation": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "operation", + "detail" + ], + "additionalProperties": true + }, + "Threads": { + "type": "array", + "items": { + "$ref": "#/$defs/ThreadsEntry" + } + }, + "ThreadsEntry": { + "type": "object", + "properties": { + "createdBy": { + "type": "string", + "enum": [ + "user", + "agent", + "system" + ] + }, + "creationSource": { + "type": "string", + "enum": [ + "web", + "mobile", + "mcp", + "provider", + "server" + ] + }, + "id": { + "type": "string" + }, + "projectId": { + "type": "string" + }, + "title": { + "type": "string" + }, + "providerInstanceId": { + "type": "string" + }, + "modelSelection": { + "$ref": "#/$defs/ModelSelection" + }, + "runtimeMode": { + "type": "string", + "enum": [ + "approval-required", + "auto-accept-edits", + "auto", + "full-access" + ] + }, + "interactionMode": { + "type": "string", + "enum": [ + "default", + "plan" + ] + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "worktreePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "linkedPullRequest": { + "anyOf": [ + { + "$ref": "#/$defs/LinkedPullRequest" + }, + { + "type": "null" + } + ] + }, + "pullRequests": { + "anyOf": [ + { + "$ref": "#/$defs/PullRequests" + }, + { + "type": "null" + } + ] + }, + "branchPullRequest": { + "anyOf": [ + { + "$ref": "#/$defs/LinkedPullRequest" + }, + { + "type": "null" + } + ] + }, + "lineage": { + "$ref": "#/$defs/Lineage" + }, + "forkedFrom": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "run" + ] + }, + "threadId": { + "type": "string" + }, + "runId": { + "type": "string" + } + }, + "required": [ + "type", + "threadId", + "runId" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "node" + ] + }, + "nodeId": { + "type": "string" + } + }, + "required": [ + "type", + "nodeId" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/ForkedFrom" + }, + { + "type": "null" + } + ] + }, + "activeProviderThreadId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "historyOrigin": { + "anyOf": [ + { + "type": "string", + "enum": [ + "native", + "v1_import" + ] + }, + { + "type": "null" + } + ] + }, + "linkOrigin": { + "anyOf": [ + { + "$ref": "#/$defs/LinkOrigin" + }, + { + "type": "null" + } + ] + }, + "delegatedFrom": { + "anyOf": [ + { + "type": "object", + "properties": { + "environmentId": { + "type": "string" + }, + "threadId": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "environmentId", + "threadId", + "title" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "latestRunId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "latestRunRequestedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "latestRunStartedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "latestRunCompletedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "latestRunStopRequested": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "activeRunId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "activityRunStartedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "activityRunStatus": { + "anyOf": [ + { + "type": "string", + "enum": [ + "preparing", + "starting", + "running", + "waiting" + ] + }, + { + "type": "null" + } + ] + }, + "blockedOnThreadWait": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "status": { + "type": "string", + "enum": [ + "idle", + "preparing", + "queued", + "starting", + "running", + "waiting", + "completed", + "interrupted", + "failed", + "cancelled", + "rolled_back" + ] + }, + "lastError": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "lastErrorClass": { + "anyOf": [ + { + "type": "string", + "enum": [ + "usage_limit", + "provider_error", + "transport_error", + "permission_error", + "validation_error", + "unknown" + ] + }, + { + "type": "null" + } + ] + }, + "lastErrorCode": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "usageLimitResetAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "pendingRuntimeRequest": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "command", + "file-read", + "file-change", + "mcp-elicitation", + "permission", + "dynamic_tool_call", + "user_input", + "auth_refresh" + ] + }, + "createdAt": { + "type": "string" + }, + "blocking": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "kind", + "createdAt" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "pendingGuestMessageCount": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "latestVisibleMessage": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "role": { + "type": "string", + "enum": [ + "user", + "assistant", + "system" + ] + }, + "text": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "role", + "text", + "updatedAt" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "latestUserMessageAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "latestUserAuthoredMessageAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasActionableProposedPlan": { + "type": "boolean" + }, + "pendingBackgroundTasks": { + "anyOf": [ + { + "$ref": "#/$defs/PendingBackgroundTasks" + }, + { + "type": "null" + } + ] + }, + "providerInstanceHistory": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "goal": { + "anyOf": [ + { + "$ref": "#/$defs/Goal" + }, + { + "type": "null" + } + ] + }, + "itemCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "visibleItemCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + }, + "archivedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "settledOverride": { + "anyOf": [ + { + "type": "string", + "enum": [ + "settled", + "active" + ] + }, + { + "type": "null" + } + ] + }, + "settledAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "unsettledAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "snoozedUntil": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "snoozedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "limitRecovery": { + "anyOf": [ + { + "$ref": "#/$defs/LimitRecovery" + }, + { + "type": "null" + } + ] + }, + "pinnedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "autoSettleDisabledAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "pinOrderKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "activeOrderKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "tags": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "string" + } + }, + { + "type": "null" + } + ] + }, + "lastVisitedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "opens": { + "anyOf": [ + { + "$ref": "#/$defs/Opens" + }, + { + "type": "null" + } + ] + }, + "titleRegeneration": { + "anyOf": [ + { + "type": "object", + "properties": { + "requestId": { + "type": "string" + }, + "startedAt": { + "type": "string" + } + }, + "required": [ + "requestId", + "startedAt" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "deletedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "createdBy", + "creationSource", + "id", + "projectId", + "title", + "providerInstanceId", + "modelSelection", + "runtimeMode", + "interactionMode", + "branch", + "worktreePath", + "lineage", + "forkedFrom", + "activeProviderThreadId", + "latestRunId", + "activeRunId", + "status", + "pendingRuntimeRequest", + "latestVisibleMessage", + "latestUserMessageAt", + "hasActionableProposedPlan", + "itemCount", + "visibleItemCount", + "createdAt", + "updatedAt", + "archivedAt", + "settledOverride", + "settledAt", + "deletedAt" + ], + "additionalProperties": true + }, + "ToolSource": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "browser", + "computer", + "integration" + ] + }, + "icon": { + "anyOf": [ + { + "$ref": "#/$defs/Icon" + }, + { + "$ref": "#/$defs/Icon2" + }, + { + "$ref": "#/$defs/Icon3" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "key", + "name", + "kind" + ], + "additionalProperties": true + }, + "Tools": { + "type": "object", + "properties": { + "hub": { + "$ref": "#/$defs/Agent" + }, + "agent": { + "$ref": "#/$defs/Agent" + } + }, + "required": [ + "hub", + "agent" + ], + "additionalProperties": true + }, + "Union_": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "identifier" + ] + }, + "name": { + "type": "string", + "minLength": 1 + } + }, + "required": [ + "type", + "name" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "not" + ] + }, + "node": { + "$ref": "#/$defs/Union_" + } + }, + "required": [ + "type", + "node" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "and" + ] + }, + "left": { + "$ref": "#/$defs/Union_" + }, + "right": { + "$ref": "#/$defs/Union_" + } + }, + "required": [ + "type", + "left", + "right" + ], + "additionalProperties": true + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "or" + ] + }, + "left": { + "$ref": "#/$defs/Union_" + }, + "right": { + "$ref": "#/$defs/Union_" + } + }, + "required": [ + "type", + "left", + "right" + ], + "additionalProperties": true + } + ] + }, + "UsageLimitSourceErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "UsageLimitSourceError" + ] + }, + "detail": { + "type": "string" + } + }, + "required": [ + "_tag", + "detail" + ], + "additionalProperties": true + }, + "UsageLimitSources2": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "cliproxy" + ] + }, + "label": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "url": { + "type": "string" + }, + "managementKey": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "object", + "properties": { + "provider": { + "type": "string" + }, + "reference": { + "type": "string" + } + }, + "required": [ + "provider", + "reference" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "enabled": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "kind", + "url" + ], + "additionalProperties": true + }, + "UsageLimitSources3": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "cliproxy" + ] + }, + "label": { + "type": "string" + }, + "checkedAt": { + "type": "string" + }, + "accounts": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "driver": { + "type": "string" + }, + "email": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "plan": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "workspaceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "usageLimits": { + "$ref": "#/$defs/UsageLimits" + } + }, + "required": [ + "id", + "driver", + "usageLimits" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "kind", + "label", + "checkedAt", + "accounts" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "UsageLimits": { + "type": "object", + "properties": { + "checkedAt": { + "type": "string" + }, + "windows": { + "type": "array", + "items": { + "anyOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "session", + "weekly", + "monthly", + "other" + ] + }, + "label": { + "type": "string" + }, + "usedPercent": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "resetsAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "windowDurationMins": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "id", + "kind", + "label", + "usedPercent" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "credentialFingerprint": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "resetCredits": { + "anyOf": [ + { + "type": "object", + "properties": { + "availableCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "nextExpiresAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "nextCreditId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "availableCount" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "externalUsage": { + "anyOf": [ + { + "type": "object", + "properties": { + "label": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "label", + "url" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "unavailable": { + "anyOf": [ + { + "type": "object", + "properties": { + "reason": { + "type": "string", + "enum": [ + "unsupported", + "probeFailed" + ] + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "reason" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "checkedAt", + "windows" + ], + "additionalProperties": true + }, + "UsagePriceOverrides2": { + "type": "object", + "properties": { + "inputCostPerMillionTokens": { + "type": "number", + "minimum": 0 + }, + "outputCostPerMillionTokens": { + "type": "number", + "minimum": 0 + }, + "cacheReadCostPerMillionTokens": { + "type": "number", + "minimum": 0 + }, + "cacheWriteCostPerMillionTokens": { + "type": "number", + "minimum": 0 + } + }, + "required": [ + "inputCostPerMillionTokens", + "outputCostPerMillionTokens" + ], + "additionalProperties": true + }, + "UsageReadErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "UsageReadError" + ] + }, + "reason": { + "type": "string", + "enum": [ + "scanFailed", + "invalidWindow" + ] + }, + "detail": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "reason", + "detail" + ], + "additionalProperties": true + }, + "VcsCreateRefError": { + "anyOf": [ + { + "$ref": "#/$defs/GitCommandErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "VcsCreateRefPayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "refName": { + "type": "string" + }, + "switchRef": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "cwd", + "refName" + ], + "additionalProperties": true + }, + "VcsCreateRefSuccess": { + "type": "object", + "properties": { + "refName": { + "type": "string" + } + }, + "required": [ + "refName" + ], + "additionalProperties": true + }, + "VcsCreateWorktreePayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "refName": { + "type": "string" + }, + "newRefName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "baseRefName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "path": { + "type": "null" + } + }, + "required": [ + "cwd", + "refName", + "path" + ], + "additionalProperties": true + }, + "VcsCreateWorktreeSuccess": { + "type": "object", + "properties": { + "worktree": { + "type": "object", + "properties": { + "path": { + "type": "string" + }, + "refName": { + "type": "string" + } + }, + "required": [ + "path", + "refName" + ], + "additionalProperties": true + } + }, + "required": [ + "worktree" + ], + "additionalProperties": true + }, + "VcsInitError": { + "anyOf": [ + { + "$ref": "#/$defs/VcsProcessSpawnErrorEncoded" + }, + { + "$ref": "#/$defs/VcsProcessExitErrorEncoded" + }, + { + "$ref": "#/$defs/VcsProcessTimeoutErrorEncoded" + }, + { + "$ref": "#/$defs/VcsProcessStdinWriteErrorEncoded" + }, + { + "$ref": "#/$defs/VcsProcessOutputReadErrorEncoded" + }, + { + "$ref": "#/$defs/VcsProcessOutputLimitErrorEncoded" + }, + { + "$ref": "#/$defs/VcsProcessMissingExitCodeErrorEncoded" + }, + { + "$ref": "#/$defs/VcsRepositoryDetectionErrorEncoded" + }, + { + "$ref": "#/$defs/VcsUnsupportedOperationErrorEncoded" + }, + { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + ] + }, + "VcsInitPayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "kind": { + "anyOf": [ + { + "type": "string", + "enum": [ + "git", + "jj", + "unknown" + ] + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "cwd" + ], + "additionalProperties": true + }, + "VcsListRefsPayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "query": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "cursor": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "includeMatchingRemoteRefs": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "refKind": { + "anyOf": [ + { + "type": "string", + "enum": [ + "all", + "local", + "remote" + ] + }, + { + "type": "null" + } + ] + }, + "refresh": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "limit": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 200 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "cwd" + ], + "additionalProperties": true + }, + "VcsListRefsSuccess": { + "type": "object", + "properties": { + "isAtRepositoryRoot": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "refs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "isRemote": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "remoteName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "upstreamRef": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "current": { + "type": "boolean" + }, + "isDefault": { + "type": "boolean" + }, + "worktreePath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "name", + "current", + "isDefault", + "worktreePath" + ], + "additionalProperties": true + } + }, + "isRepo": { + "type": "boolean" + }, + "hasPrimaryRemote": { + "type": "boolean" + }, + "nextCursor": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "totalCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "refs", + "isRepo", + "hasPrimaryRemote", + "nextCursor", + "totalCount" + ], + "additionalProperties": true + }, + "VcsProcessExitErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "VcsProcessExitError" + ] + }, + "operation": { + "type": "string" + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "argumentCount": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "exitCode": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + }, + "detail": { + "type": "string" + }, + "failureKind": { + "anyOf": [ + { + "type": "string", + "enum": [ + "authentication", + "not-found", + "rate-limited", + "command-failed" + ] + }, + { + "type": "null" + } + ] + }, + "retryable": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + }, + "stderrLength": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "stderrTruncated": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "operation", + "command", + "cwd", + "exitCode", + "detail" + ], + "additionalProperties": true + }, + "VcsProcessMissingExitCodeErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "VcsProcessMissingExitCodeError" + ] + }, + "operation": { + "type": "string" + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "argumentCount": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "operation", + "command", + "cwd" + ], + "additionalProperties": true + }, + "VcsProcessOutputLimitErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "VcsProcessOutputLimitError" + ] + }, + "operation": { + "type": "string" + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "argumentCount": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "stream": { + "type": "string", + "enum": [ + "stdout", + "stderr" + ] + }, + "maxBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "observedBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "_tag", + "operation", + "command", + "cwd", + "stream", + "maxBytes", + "observedBytes" + ], + "additionalProperties": true + }, + "VcsProcessOutputReadErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "VcsProcessOutputReadError" + ] + }, + "operation": { + "type": "string" + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "argumentCount": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "stream": { + "type": "string", + "enum": [ + "stdout", + "stderr", + "exitCode" + ] + }, + "cause": {} + }, + "required": [ + "_tag", + "operation", + "command", + "cwd", + "stream", + "cause" + ], + "additionalProperties": true + }, + "VcsProcessSpawnErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "VcsProcessSpawnError" + ] + }, + "operation": { + "type": "string" + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "argumentCount": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "cause": {} + }, + "required": [ + "_tag", + "operation", + "command", + "cwd", + "cause" + ], + "additionalProperties": true + }, + "VcsProcessStdinWriteErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "VcsProcessStdinWriteError" + ] + }, + "operation": { + "type": "string" + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "argumentCount": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "stdinBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "cause": {} + }, + "required": [ + "_tag", + "operation", + "command", + "cwd", + "stdinBytes", + "cause" + ], + "additionalProperties": true + }, + "VcsProcessTimeoutErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "VcsProcessTimeoutError" + ] + }, + "operation": { + "type": "string" + }, + "command": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "argumentCount": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "timeoutMs": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "string", + "enum": [ + "Infinity", + "-Infinity", + "NaN" + ] + } + ] + } + }, + "required": [ + "_tag", + "operation", + "command", + "cwd", + "timeoutMs" + ], + "additionalProperties": true + }, + "VcsPullSuccess": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "pulled", + "skipped_up_to_date" + ] + }, + "refName": { + "type": "string" + }, + "upstreamRef": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "status", + "refName", + "upstreamRef" + ], + "additionalProperties": true + }, + "VcsRefreshStatusPayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "localOnly": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "cwd" + ], + "additionalProperties": true + }, + "VcsRefreshStatusSuccess": { + "type": "object", + "properties": { + "isRepo": { + "type": "boolean" + }, + "sourceControlProvider": { + "anyOf": [ + { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "name": { + "type": "string" + }, + "baseUrl": { + "type": "string" + } + }, + "required": [ + "kind", + "name", + "baseUrl" + ], + "additionalProperties": true + }, + { + "type": "null" + } + ] + }, + "hasPrimaryRemote": { + "type": "boolean" + }, + "isDefaultRef": { + "type": "boolean" + }, + "refName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "hasWorkingTreeChanges": { + "type": "boolean" + }, + "workingTree": { + "$ref": "#/$defs/WorkingTree" + }, + "branchChanges": { + "anyOf": [ + { + "$ref": "#/$defs/BranchChanges" + }, + { + "type": "null" + } + ] + }, + "hasUpstream": { + "type": "boolean" + }, + "aheadCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "behindCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "aheadOfDefaultCount": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "pr": { + "anyOf": [ + { + "$ref": "#/$defs/Pr" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "isRepo", + "hasPrimaryRemote", + "isDefaultRef", + "refName", + "hasWorkingTreeChanges", + "workingTree", + "hasUpstream", + "aheadCount", + "behindCount", + "pr" + ], + "additionalProperties": true + }, + "VcsRemoveWorktreePayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "path": { + "type": "string" + }, + "force": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "cwd", + "path" + ], + "additionalProperties": true + }, + "VcsRepositoryDetectionErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "VcsRepositoryDetectionError" + ] + }, + "operation": { + "type": "string" + }, + "cwd": { + "type": "string" + }, + "detail": { + "type": "string" + }, + "cause": { + "anyOf": [ + {}, + { + "type": "null" + } + ] + } + }, + "required": [ + "_tag", + "operation", + "cwd", + "detail" + ], + "additionalProperties": true + }, + "VcsSwitchRefPayload": { + "type": "object", + "properties": { + "cwd": { + "type": "string" + }, + "refName": { + "type": "string" + } + }, + "required": [ + "cwd", + "refName" + ], + "additionalProperties": true + }, + "VcsSwitchRefSuccess": { + "type": "object", + "properties": { + "refName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "refName" + ], + "additionalProperties": true + }, + "VcsUnsupportedOperationErrorEncoded": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "VcsUnsupportedOperationError" + ] + }, + "operation": { + "type": "string" + }, + "kind": { + "type": "string", + "enum": [ + "git", + "jj", + "unknown" + ] + }, + "detail": { + "type": "string" + } + }, + "required": [ + "_tag", + "operation", + "kind", + "detail" + ], + "additionalProperties": true + }, + "Viewport": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "freeform" + ] + }, + "width": { + "type": "integer", + "minimum": 240, + "maximum": 3840 + }, + "height": { + "type": "integer", + "minimum": 240, + "maximum": 3840 + } + }, + "required": [ + "_tag", + "width", + "height" + ], + "additionalProperties": true + }, + "Viewport2": { + "type": "object", + "properties": { + "_tag": { + "type": "string", + "enum": [ + "preset" + ] + }, + "width": { + "type": "integer", + "minimum": 240, + "maximum": 3840 + }, + "height": { + "type": "integer", + "minimum": 240, + "maximum": 3840 + }, + "presetId": { + "type": "string", + "enum": [ + "iphone-se", + "iphone-xr", + "iphone-12-pro", + "iphone-14-pro-max", + "pixel-7", + "samsung-galaxy-s8-plus", + "samsung-galaxy-s20-ultra", + "ipad-mini", + "ipad-air", + "ipad-pro", + "surface-pro-7", + "surface-duo", + "galaxy-z-fold-5", + "asus-zenbook-fold", + "samsung-galaxy-a51-71", + "nest-hub", + "nest-hub-max", + "desktop-1920x1080", + "desktop-1440x900", + "laptop-1366x768", + "laptop-1280x800", + "ipad-pro-11", + "iphone-15-pro", + "pixel-8", + "galaxy-s24" + ] + } + }, + "required": [ + "_tag", + "width", + "height", + "presetId" + ], + "additionalProperties": true + }, + "WorkingTree": { + "type": "object", + "properties": { + "rootPath": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "string", + "minLength": 1 + }, + "insertions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "deletions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "path", + "insertions", + "deletions" + ], + "additionalProperties": true + } + }, + "insertions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "deletions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "files", + "insertions", + "deletions" + ], + "additionalProperties": true + }, + "WorkspaceStrategy": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "existing_worktree" + ] + }, + "worktreePath": { + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "worktreePath" + ], + "additionalProperties": true + }, + "WorkspaceStrategy2": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "worktree" + ] + }, + "baseRef": { + "type": "string" + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "startFromOrigin": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type", + "baseRef" + ], + "additionalProperties": true + }, + "WorkspaceStrategy3": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "root" + ] + }, + "branch": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "type" + ], + "additionalProperties": true + }, + { + "$ref": "#/$defs/WorkspaceStrategy" + }, + { + "$ref": "#/$defs/WorkspaceStrategy2" + } + ] + }, + "WorktreeCleanup": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "custom" + ] + }, + "rules": { + "type": "object", + "properties": { + "worktreeKeepWhen": { + "anyOf": [ + { + "type": "string", + "enum": [ + "any-local-files", + "uncommitted-changes", + "tracked-changes" + ] + }, + { + "type": "null" + } + ] + }, + "worktreeAfterDays": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 3650 + }, + { + "type": "null" + } + ] + }, + "worktreeSettledAfterDays": { + "anyOf": [ + { + "type": "integer", + "minimum": 0, + "maximum": 3650 + }, + { + "type": "null" + } + ] + }, + "worktreeOnMerge": { + "type": "boolean" + }, + "worktreeOnDelete": { + "type": "boolean" + }, + "worktreeUnchanged": { + "type": "boolean" + } + }, + "required": [ + "worktreeAfterDays", + "worktreeOnMerge", + "worktreeOnDelete", + "worktreeUnchanged" + ], + "additionalProperties": true + } + }, + "required": [ + "mode", + "rules" + ], + "additionalProperties": true + }, + "WorktreeSetupCancelSuccess": { + "type": "object", + "properties": { + "cancelled": { + "type": "boolean" + } + }, + "required": [ + "cancelled" + ], + "additionalProperties": true + } + }, + "methods": { + "agentSessions.import": { + "stream": false, + "payload": { + "$ref": "#/$defs/AgentSessionsImportPayload" + }, + "success": { + "$ref": "#/$defs/AgentSessionsImportSuccess" + }, + "error": { + "$ref": "#/$defs/AgentSessionsImportError" + } + }, + "agentSessions.scan": { + "stream": false, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/AgentSessionsScanSuccess" + }, + "error": { + "$ref": "#/$defs/AgentSessionsScanError" + } + }, + "assets.createUrl": { + "stream": false, + "payload": { + "$ref": "#/$defs/AssetsCreateUrlPayload" + }, + "success": { + "$ref": "#/$defs/AssetsCreateUrlSuccess" + }, + "error": { + "$ref": "#/$defs/AssetsCreateUrlError" + } + }, + "assets.persistChatAttachments": { + "stream": false, + "payload": { + "$ref": "#/$defs/AssetsPersistChatAttachmentsPayload" + }, + "success": { + "$ref": "#/$defs/AssetsPersistChatAttachmentsSuccess" + }, + "error": { + "$ref": "#/$defs/AssetsPersistChatAttachmentsError" + } + }, + "attachments.createUploadUrl": { + "stream": false, + "payload": { + "$ref": "#/$defs/AttachmentsCreateUploadUrlPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsCreateUploadUrlSuccess" + }, + "error": { + "$ref": "#/$defs/AttachmentsCreateUploadUrlError" + } + }, + "attachments.delete": { + "stream": false, + "payload": { + "$ref": "#/$defs/AttachmentsDeletePayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "cloud.getRelayClientStatus": { + "stream": false, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/CloudGetRelayClientStatusSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "cloud.installRelayClient": { + "stream": true, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/CloudInstallRelayClientSuccess" + }, + "error": { + "$ref": "#/$defs/CloudInstallRelayClientError" + } + }, + "device.action": { + "stream": false, + "payload": { + "$ref": "#/$defs/DeviceActionPayload" + }, + "success": { + "$ref": "#/$defs/DeviceActionSuccess" + }, + "error": { + "$ref": "#/$defs/DeviceActionError" + } + }, + "device.close": { + "stream": false, + "payload": { + "$ref": "#/$defs/DeviceClosePayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/DeviceActionError" + } + }, + "device.configure": { + "stream": false, + "payload": { + "$ref": "#/$defs/DeviceConfigurePayload" + }, + "success": { + "$ref": "#/$defs/DeviceConfigureSuccess" + }, + "error": { + "$ref": "#/$defs/DeviceActionError" + } + }, + "device.detail": { + "stream": false, + "payload": { + "$ref": "#/$defs/DeviceDetailPayload" + }, + "success": { + "$ref": "#/$defs/DeviceActionSuccess" + }, + "error": { + "$ref": "#/$defs/DeviceActionError" + } + }, + "device.easAccount": { + "stream": false, + "payload": { + "$ref": "#/$defs/DeviceEasAccountPayload" + }, + "success": { + "$ref": "#/$defs/DeviceEasAccountSuccess" + }, + "error": { + "$ref": "#/$defs/DeviceEasAccountError" + } + }, + "device.list": { + "stream": false, + "payload": { + "$ref": "#/$defs/DeviceListPayload" + }, + "success": { + "$ref": "#/$defs/DeviceConfigureSuccess" + }, + "error": { + "$ref": "#/$defs/DeviceActionError" + } + }, + "device.open": { + "stream": false, + "payload": { + "$ref": "#/$defs/DeviceOpenPayload" + }, + "success": { + "$ref": "#/$defs/SessionsEntry" + }, + "error": { + "$ref": "#/$defs/DeviceActionError" + } + }, + "device.shutdown": { + "stream": false, + "payload": { + "$ref": "#/$defs/DeviceShutdownPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/DeviceActionError" + } + }, + "device.simlockIdentity": { + "stream": false, + "payload": { + "$ref": "#/$defs/DeviceSimlockIdentityPayload" + }, + "success": { + "$ref": "#/$defs/DeviceSimlockIdentitySuccess" + }, + "error": { + "$ref": "#/$defs/DeviceSimlockIdentityError" + } + }, + "device.testHost": { + "stream": false, + "payload": { + "$ref": "#/$defs/DeviceTestHostPayload" + }, + "success": { + "$ref": "#/$defs/DeviceTestHostSuccess" + }, + "error": { + "$ref": "#/$defs/DeviceActionError" + } + }, + "filesystem.browse": { + "stream": false, + "payload": { + "$ref": "#/$defs/FilesystemBrowsePayload" + }, + "success": { + "$ref": "#/$defs/FilesystemBrowseSuccess" + }, + "error": { + "$ref": "#/$defs/FilesystemBrowseError" + } + }, + "filesystem.getMetadata": { + "stream": false, + "payload": { + "$ref": "#/$defs/FilesystemGetMetadataPayload" + }, + "success": { + "$ref": "#/$defs/FilesystemGetMetadataSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "git.preparePullRequestThread": { + "stream": false, + "payload": { + "$ref": "#/$defs/GitPreparePullRequestThreadPayload" + }, + "success": { + "$ref": "#/$defs/GitPreparePullRequestThreadSuccess" + }, + "error": { + "$ref": "#/$defs/GitPreparePullRequestThreadError" + } + }, + "git.resolvePullRequest": { + "stream": false, + "payload": { + "$ref": "#/$defs/GitResolvePullRequestPayload" + }, + "success": { + "$ref": "#/$defs/GitResolvePullRequestSuccess" + }, + "error": { + "$ref": "#/$defs/GitPreparePullRequestThreadError" + } + }, + "git.runStackedAction": { + "stream": true, + "payload": { + "$ref": "#/$defs/GitRunStackedActionPayload" + }, + "success": { + "$ref": "#/$defs/GitRunStackedActionSuccess" + }, + "error": { + "$ref": "#/$defs/GitPreparePullRequestThreadError" + } + }, + "guestShare.create": { + "stream": false, + "payload": { + "$ref": "#/$defs/GuestShareCreatePayload" + }, + "success": { + "$ref": "#/$defs/GuestShareCreateSuccess" + }, + "error": { + "$ref": "#/$defs/GuestShareCreateError" + } + }, + "mcpApps.callTool": { + "stream": false, + "payload": { + "$ref": "#/$defs/McpAppsCallToolPayload" + }, + "success": { + "$ref": "#/$defs/McpAppsCallToolSuccess" + }, + "error": { + "$ref": "#/$defs/McpAppsCallToolError" + } + }, + "mcpApps.readResource": { + "stream": false, + "payload": { + "$ref": "#/$defs/McpAppsReadResourcePayload" + }, + "success": { + "$ref": "#/$defs/McpAppsReadResourceSuccess" + }, + "error": { + "$ref": "#/$defs/McpAppsCallToolError" + } + }, + "mcpApps.toolInfo": { + "stream": false, + "payload": { + "$ref": "#/$defs/McpAppsToolInfoPayload" + }, + "success": { + "$ref": "#/$defs/McpAppsToolInfoSuccess" + }, + "error": { + "$ref": "#/$defs/McpAppsCallToolError" + } + }, + "mcpApps.updateModelContext": { + "stream": false, + "payload": { + "$ref": "#/$defs/McpAppsUpdateModelContextPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/McpAppsCallToolError" + } + }, + "orchestration.dispatchCommand": { + "stream": false, + "payload": { + "$ref": "#/$defs/OrchestrationDispatchCommandPayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationDispatchCommandSuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationDispatchCommandError" + } + }, + "orchestration.forceStopThread": { + "stream": false, + "payload": { + "$ref": "#/$defs/OrchestrationForceStopThreadPayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationForceStopThreadSuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationForceStopThreadError" + } + }, + "orchestration.getArchivedShellSnapshot": { + "stream": false, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationGetArchivedShellSnapshotSuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationGetArchivedShellSnapshotError" + } + }, + "orchestration.getFullThreadDiff": { + "stream": false, + "payload": { + "$ref": "#/$defs/OrchestrationGetFullThreadDiffPayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationTurnDiffRange" + }, + "error": { + "$ref": "#/$defs/OrchestrationGetFullThreadDiffError" + } + }, + "orchestration.getNativeChildWork": { + "stream": false, + "payload": { + "$ref": "#/$defs/GuestShareCreatePayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationGetNativeChildWorkSuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationGetNativeChildWorkError" + } + }, + "orchestration.getOlderSettledThreads": { + "stream": false, + "payload": { + "$ref": "#/$defs/OrchestrationGetOlderSettledThreadsPayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationGetOlderSettledThreadsSuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationGetArchivedShellSnapshotError" + } + }, + "orchestration.getThreadFamily": { + "stream": false, + "payload": { + "$ref": "#/$defs/GuestShareCreatePayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationGetThreadFamilySuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationGetArchivedShellSnapshotError" + } + }, + "orchestration.getThreadProjection": { + "stream": false, + "payload": { + "$ref": "#/$defs/GuestShareCreatePayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationGetThreadProjectionSuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationGetThreadProjectionError" + } + }, + "orchestration.getTurnDiff": { + "stream": false, + "payload": { + "$ref": "#/$defs/OrchestrationTurnDiffRange2" + }, + "success": { + "$ref": "#/$defs/OrchestrationTurnDiffRange" + }, + "error": { + "$ref": "#/$defs/OrchestrationGetTurnDiffError" + } + }, + "orchestration.getTurnItem": { + "stream": false, + "payload": { + "$ref": "#/$defs/OrchestrationGetTurnItemPayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationGetTurnItemSuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationGetThreadProjectionError" + } + }, + "orchestration.getWorkflowScript": { + "stream": false, + "payload": { + "$ref": "#/$defs/OrchestrationGetWorkflowScriptPayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationGetWorkflowScriptSuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationGetWorkflowScriptError" + } + }, + "orchestration.getWorktreeThreadIds": { + "stream": false, + "payload": { + "$ref": "#/$defs/OrchestrationGetWorktreeThreadIdsPayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationGetWorktreeThreadIdsSuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationGetArchivedShellSnapshotError" + } + }, + "orchestration.launchThread": { + "stream": false, + "payload": { + "$ref": "#/$defs/OrchestrationLaunchThreadPayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationLaunchThreadSuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationLaunchThreadError" + } + }, + "orchestration.searchThread": { + "stream": false, + "payload": { + "$ref": "#/$defs/OrchestrationSearchThreadPayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationSearchThreadSuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationSearchThreadError" + } + }, + "orchestration.searchThreads": { + "stream": false, + "payload": { + "$ref": "#/$defs/OrchestrationSearchThreadsPayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationSearchThreadsSuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationSearchThreadsError" + } + }, + "orchestration.searchThreadStream": { + "stream": true, + "payload": { + "$ref": "#/$defs/OrchestrationSearchThreadPayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationSearchThreadSuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationSearchThreadError" + } + }, + "orchestration.stopWorkflow": { + "stream": false, + "payload": { + "$ref": "#/$defs/OrchestrationStopWorkflowPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationStopWorkflowError" + } + }, + "orchestration.subscribeCommandOutput": { + "stream": true, + "payload": { + "$ref": "#/$defs/OrchestrationSubscribeCommandOutputPayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationSubscribeCommandOutputSuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationSubscribeCommandOutputError" + } + }, + "orchestration.subscribeShell": { + "stream": true, + "payload": { + "$ref": "#/$defs/OrchestrationSubscribeShellPayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationSubscribeShellSuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationGetArchivedShellSnapshotError" + } + }, + "orchestration.subscribeThread": { + "stream": true, + "payload": { + "$ref": "#/$defs/OrchestrationSubscribeThreadPayload" + }, + "success": { + "$ref": "#/$defs/OrchestrationSubscribeThreadSuccess" + }, + "error": { + "$ref": "#/$defs/OrchestrationGetThreadProjectionError" + } + }, + "peerLinks.answerRequest": { + "stream": false, + "payload": { + "$ref": "#/$defs/PeerLinksAnswerRequestPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PeerLinksAnswerRequestError" + } + }, + "peerLinks.link": { + "stream": false, + "payload": { + "$ref": "#/$defs/PeerLinksLinkPayload" + }, + "success": { + "$ref": "#/$defs/PeerLinksLinkSuccess" + }, + "error": { + "$ref": "#/$defs/PeerLinksLinkError" + } + }, + "peerLinks.list": { + "stream": false, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/PeerLinksListSuccess" + }, + "error": { + "$ref": "#/$defs/PeerLinksLinkError" + } + }, + "peerLinks.unlink": { + "stream": false, + "payload": { + "$ref": "#/$defs/PeerLinksUnlinkPayload" + }, + "success": { + "$ref": "#/$defs/PeerLinksUnlinkSuccess" + }, + "error": { + "$ref": "#/$defs/PeerLinksLinkError" + } + }, + "preview.adjust": { + "stream": false, + "payload": { + "$ref": "#/$defs/PreviewAdjustPayload" + }, + "success": { + "$ref": "#/$defs/PreviewAdjustSuccess" + }, + "error": { + "$ref": "#/$defs/PreviewAdjustError" + } + }, + "preview.clearProfile": { + "stream": false, + "payload": { + "$ref": "#/$defs/PreviewClearProfilePayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PreviewClearProfileError" + } + }, + "preview.close": { + "stream": false, + "payload": { + "$ref": "#/$defs/PreviewClosePayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PreviewAdjustError" + } + }, + "preview.list": { + "stream": false, + "payload": { + "$ref": "#/$defs/GuestShareCreatePayload" + }, + "success": { + "$ref": "#/$defs/PreviewListSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "preview.open": { + "stream": false, + "payload": { + "$ref": "#/$defs/PreviewOpenPayload" + }, + "success": { + "$ref": "#/$defs/PreviewAdjustSuccess" + }, + "error": { + "$ref": "#/$defs/PreviewAdjustError" + } + }, + "preview.reportProfiles": { + "stream": false, + "payload": { + "$ref": "#/$defs/PreviewReportProfilesPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "preview.reportStatus": { + "stream": false, + "payload": { + "$ref": "#/$defs/PreviewReportStatusPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PreviewAdjustError" + } + }, + "preview.resize": { + "stream": false, + "payload": { + "$ref": "#/$defs/PreviewResizePayload" + }, + "success": { + "$ref": "#/$defs/PreviewAdjustSuccess" + }, + "error": { + "$ref": "#/$defs/PreviewAdjustError" + } + }, + "preview.stopLocalServer": { + "stream": false, + "payload": { + "$ref": "#/$defs/PreviewStopLocalServerPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PreviewStopLocalServerError" + } + }, + "projectClone.cancel": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProjectCloneCancelPayload" + }, + "success": { + "$ref": "#/$defs/ProjectCloneCancelSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "projectClone.retry": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProjectCloneCancelPayload" + }, + "success": { + "$ref": "#/$defs/ProjectCloneCancelSuccess" + }, + "error": { + "$ref": "#/$defs/ProjectCloneRetryError" + } + }, + "projectClone.start": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProjectCloneStartPayload" + }, + "success": { + "$ref": "#/$defs/ProjectCloneStartSuccess" + }, + "error": { + "$ref": "#/$defs/ProjectCloneStartError" + } + }, + "projects.createFromThread": { + "stream": false, + "payload": { + "$ref": "#/$defs/GuestShareCreatePayload" + }, + "success": { + "$ref": "#/$defs/ProjectCloneCancelPayload" + }, + "error": { + "$ref": "#/$defs/ProjectsCreateFromThreadError" + } + }, + "projects.createNew": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProjectsCreateNewPayload" + }, + "success": { + "$ref": "#/$defs/ProjectsCreateNewSuccess" + }, + "error": { + "$ref": "#/$defs/ProjectsCreateFromThreadError" + } + }, + "projects.ensureScratch": { + "stream": false, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/ProjectCloneCancelPayload" + }, + "error": { + "$ref": "#/$defs/ProjectsCreateFromThreadError" + } + }, + "projects.listEntries": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProjectsListEntriesPayload" + }, + "success": { + "$ref": "#/$defs/ProjectsListEntriesSuccess" + }, + "error": { + "$ref": "#/$defs/ProjectsListEntriesError" + } + }, + "projects.listIgnoredFiles": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProjectsListIgnoredFilesPayload" + }, + "success": { + "$ref": "#/$defs/ProjectsListIgnoredFilesSuccess" + }, + "error": { + "$ref": "#/$defs/ProjectsListEntriesError" + } + }, + "projects.mutate": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProjectsMutatePayload" + }, + "success": { + "$ref": "#/$defs/ProjectsMutateSuccess" + }, + "error": { + "$ref": "#/$defs/ProjectsMutateError" + } + }, + "projects.readFile": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProjectsReadFilePayload" + }, + "success": { + "$ref": "#/$defs/ProjectsReadFileSuccess" + }, + "error": { + "$ref": "#/$defs/ProjectsReadFileError" + } + }, + "projects.searchContents": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProjectsSearchContentsPayload" + }, + "success": { + "$ref": "#/$defs/ProjectsSearchContentsSuccess" + }, + "error": { + "$ref": "#/$defs/ProjectsSearchContentsError" + } + }, + "projects.searchEntries": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProjectsSearchEntriesPayload" + }, + "success": { + "$ref": "#/$defs/ProjectsListEntriesSuccess" + }, + "error": { + "$ref": "#/$defs/ProjectsSearchEntriesError" + } + }, + "projects.writeFile": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProjectsWriteFilePayload" + }, + "success": { + "$ref": "#/$defs/ProjectsWriteFileSuccess" + }, + "error": { + "$ref": "#/$defs/ProjectsWriteFileError" + } + }, + "provider.auth.cancel": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProviderAuthCancelPayload" + }, + "success": { + "$ref": "#/$defs/ProviderAuthCancelSuccess" + }, + "error": { + "$ref": "#/$defs/ProviderAuthCancelError" + } + }, + "provider.auth.complete": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProviderAuthCompletePayload" + }, + "success": { + "$ref": "#/$defs/ProviderAuthCancelSuccess" + }, + "error": { + "$ref": "#/$defs/ProviderAuthCancelError" + } + }, + "provider.auth.logout": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProviderAuthLogoutPayload" + }, + "success": { + "$ref": "#/$defs/ProviderAuthCancelSuccess" + }, + "error": { + "$ref": "#/$defs/ProviderAuthCancelError" + } + }, + "provider.auth.respond": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProviderAuthRespondPayload" + }, + "success": { + "$ref": "#/$defs/ProviderAuthCancelSuccess" + }, + "error": { + "$ref": "#/$defs/ProviderAuthCancelError" + } + }, + "provider.auth.start": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProviderAuthStartPayload" + }, + "success": { + "$ref": "#/$defs/ProviderAuthCancelSuccess" + }, + "error": { + "$ref": "#/$defs/ProviderAuthCancelError" + } + }, + "provider.auth.subscribe": { + "stream": true, + "payload": { + "$ref": "#/$defs/ProviderAuthLogoutPayload" + }, + "success": { + "$ref": "#/$defs/ProviderAuthCancelSuccess" + }, + "error": { + "$ref": "#/$defs/ProviderAuthCancelError" + } + }, + "provider.chatgpt.handoff.subscribe": { + "stream": true, + "payload": { + "$ref": "#/$defs/ProviderChatgptHandoffSubscribePayload" + }, + "success": { + "$ref": "#/$defs/ProviderChatgptHandoffSubscribeSuccess" + }, + "error": { + "$ref": "#/$defs/ProviderAuthCancelError" + } + }, + "provider.chatgpt.import-profile": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProviderChatgptImportProfilePayload" + }, + "success": { + "$ref": "#/$defs/ProviderAuthCancelSuccess" + }, + "error": { + "$ref": "#/$defs/ProviderAuthCancelError" + } + }, + "provider.chatgpt.reconnect-profile": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProviderChatgptReconnectProfilePayload" + }, + "success": { + "$ref": "#/$defs/ProviderChatgptReconnectProfileSuccess" + }, + "error": { + "$ref": "#/$defs/ProviderAuthCancelError" + } + }, + "provider.consumeResetCredit": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProviderConsumeResetCreditPayload" + }, + "success": { + "$ref": "#/$defs/ProviderConsumeResetCreditSuccess" + }, + "error": { + "$ref": "#/$defs/ProviderConsumeResetCreditError" + } + }, + "provider.install.cancel": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProviderInstallCancelPayload" + }, + "success": { + "$ref": "#/$defs/ProviderInstallCancelSuccess" + }, + "error": { + "$ref": "#/$defs/ProviderAuthCancelError" + } + }, + "provider.install.remove": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProviderAuthLogoutPayload" + }, + "success": { + "$ref": "#/$defs/ProviderInstallCancelSuccess" + }, + "error": { + "$ref": "#/$defs/ProviderAuthCancelError" + } + }, + "provider.install.start": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProviderAuthLogoutPayload" + }, + "success": { + "$ref": "#/$defs/ProviderInstallCancelSuccess" + }, + "error": { + "$ref": "#/$defs/ProviderAuthCancelError" + } + }, + "provider.install.subscribe": { + "stream": true, + "payload": { + "$ref": "#/$defs/ProviderAuthLogoutPayload" + }, + "success": { + "$ref": "#/$defs/ProviderInstallCancelSuccess" + }, + "error": { + "$ref": "#/$defs/ProviderAuthCancelError" + } + }, + "provider.uploadFeedback": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProviderUploadFeedbackPayload" + }, + "success": { + "$ref": "#/$defs/ProviderUploadFeedbackSuccess" + }, + "error": { + "$ref": "#/$defs/ProviderUploadFeedbackError" + } + }, + "pullRequests.activity": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsActivityPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsActivitySuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.changeBreakdown": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsActivityPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsChangeBreakdownSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.checks": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsActivityPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsChecksSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.comment": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsCommentPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.detail": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsActivityPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsDetailSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.diffFileContents": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsDiffFileContentsPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsDiffFileContentsSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.filesViewed": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsActivityPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsFilesViewedSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.filterOptions": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsFilterOptionsPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsFilterOptionsSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.generateReviewGuide": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsActivityPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsGenerateReviewGuideSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.invalidate": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsInvalidatePayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.labelCandidates": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsActivityPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsLabelCandidatesSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.linkedThreads": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsActivityPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsLinkedThreadsSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.list": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsListPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsListSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.listStats": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsListStatsPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsListStatsSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.preview": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsActivityPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsPreviewSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.replyToThread": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsReplyToThreadPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.reportState": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsReportStatePayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.requestReviewers": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsRequestReviewersPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.reviewerCandidates": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsActivityPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsReviewerCandidatesSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.reviewGuide": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsActivityPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsGenerateReviewGuideSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.routing": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsActivityPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsRoutingSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.routingIdentity": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsRoutingIdentityPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsRoutingIdentitySuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.runAction": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsRunActionPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.setFilesViewed": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsSetFilesViewedPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.setLabels": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsSetLabelsPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.setReaction": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsSetReactionPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.setThreadResolution": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsSetThreadResolutionPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.stack": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsActivityPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsStackSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.submitReview": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsSubmitReviewPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.subscribeRefreshes": { + "stream": true, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsSubscribeRefreshesSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "pullRequests.summary": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsActivityPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsSummarySuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.threadComments": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsThreadCommentsPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsThreadCommentsSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.update": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsUpdatePayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "pullRequests.updateComment": { + "stream": false, + "payload": { + "$ref": "#/$defs/PullRequestsUpdateCommentPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/PullRequestsActivityError" + } + }, + "review.getBranchCommits": { + "stream": false, + "payload": { + "$ref": "#/$defs/ReviewGetBranchCommitsPayload" + }, + "success": { + "$ref": "#/$defs/ReviewGetBranchCommitsSuccess" + }, + "error": { + "$ref": "#/$defs/ReviewGetBranchCommitsError" + } + }, + "review.getDiffFileContents": { + "stream": false, + "payload": { + "$ref": "#/$defs/ReviewGetDiffFileContentsPayload" + }, + "success": { + "$ref": "#/$defs/PullRequestsDiffFileContentsSuccess" + }, + "error": { + "$ref": "#/$defs/ReviewGetBranchCommitsError" + } + }, + "review.getDiffPreview": { + "stream": false, + "payload": { + "$ref": "#/$defs/ReviewGetDiffPreviewPayload" + }, + "success": { + "$ref": "#/$defs/ReviewGetDiffPreviewSuccess" + }, + "error": { + "$ref": "#/$defs/ReviewGetBranchCommitsError" + } + }, + "scheduledTasks.delete": { + "stream": false, + "payload": { + "$ref": "#/$defs/ScheduledTasksDeletePayload" + }, + "success": { + "$ref": "#/$defs/ScheduledTasksDeletePayload" + }, + "error": { + "$ref": "#/$defs/ScheduledTasksDeleteError" + } + }, + "scheduledTasks.getWebhookDelivery": { + "stream": false, + "payload": { + "$ref": "#/$defs/ScheduledTasksGetWebhookDeliveryPayload" + }, + "success": { + "$ref": "#/$defs/ScheduledTasksGetWebhookDeliverySuccess" + }, + "error": { + "$ref": "#/$defs/ScheduledTasksDeleteError" + } + }, + "scheduledTasks.listWebhookDeliveries": { + "stream": false, + "payload": { + "$ref": "#/$defs/ScheduledTasksListWebhookDeliveriesPayload" + }, + "success": { + "$ref": "#/$defs/ScheduledTasksListWebhookDeliveriesSuccess" + }, + "error": { + "$ref": "#/$defs/ScheduledTasksDeleteError" + } + }, + "scheduledTasks.rotateWebhookToken": { + "stream": false, + "payload": { + "$ref": "#/$defs/ScheduledTasksDeletePayload" + }, + "success": { + "$ref": "#/$defs/ScheduledTasksRotateWebhookTokenSuccess" + }, + "error": { + "$ref": "#/$defs/ScheduledTasksDeleteError" + } + }, + "scheduledTasks.runNow": { + "stream": false, + "payload": { + "$ref": "#/$defs/ScheduledTasksDeletePayload" + }, + "success": { + "$ref": "#/$defs/ScheduledTasksRotateWebhookTokenSuccess" + }, + "error": { + "$ref": "#/$defs/ScheduledTasksDeleteError" + } + }, + "scheduledTasks.setEnabled": { + "stream": false, + "payload": { + "$ref": "#/$defs/ScheduledTasksSetEnabledPayload" + }, + "success": { + "$ref": "#/$defs/ScheduledTasksRotateWebhookTokenSuccess" + }, + "error": { + "$ref": "#/$defs/ScheduledTasksDeleteError" + } + }, + "scheduledTasks.subscribe": { + "stream": true, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/ScheduledTasksSubscribeSuccess" + }, + "error": { + "$ref": "#/$defs/ScheduledTasksDeleteError" + } + }, + "scheduledTasks.upsert": { + "stream": false, + "payload": { + "$ref": "#/$defs/ScheduledTasksUpsertPayload" + }, + "success": { + "$ref": "#/$defs/ScheduledTasksRotateWebhookTokenSuccess" + }, + "error": { + "$ref": "#/$defs/ScheduledTasksDeleteError" + } + }, + "secrets.answerRequest": { + "stream": false, + "payload": { + "$ref": "#/$defs/SecretsAnswerRequestPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/SecretsAnswerRequestError" + } + }, + "server.acceptAcpRegistryUrlAuth": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerAcceptAcpRegistryUrlAuthPayload" + }, + "success": { + "$ref": "#/$defs/ServerAcceptAcpRegistryUrlAuthSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "server.checkOtlpEndpoint": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerCheckOtlpEndpointPayload" + }, + "success": { + "$ref": "#/$defs/ServerCheckOtlpEndpointSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "server.checkSecretSource": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerCheckSecretSourcePayload" + }, + "success": { + "$ref": "#/$defs/ServerCheckSecretSourceSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "server.commitDesktopUpdate": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerCommitDesktopUpdatePayload" + }, + "success": { + "$ref": "#/$defs/ServerCommitDesktopUpdateSuccess" + }, + "error": { + "$ref": "#/$defs/ServerCommitDesktopUpdateError" + } + }, + "server.deleteAcpRegistrySession": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerDeleteAcpRegistrySessionPayload" + }, + "success": { + "$ref": "#/$defs/ServerDeleteAcpRegistrySessionSuccess" + }, + "error": { + "$ref": "#/$defs/ServerDeleteAcpRegistrySessionError" + } + }, + "server.disableAcpRegistryProvider": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerDisableAcpRegistryProviderPayload" + }, + "success": { + "$ref": "#/$defs/ServerDisableAcpRegistryProviderSuccess" + }, + "error": { + "$ref": "#/$defs/ServerDeleteAcpRegistrySessionError" + } + }, + "server.discoverSourceControl": { + "stream": false, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/ServerDiscoverSourceControlSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "server.getConfig": { + "stream": false, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/ServerGetConfigSuccess" + }, + "error": { + "$ref": "#/$defs/ServerGetConfigError" + } + }, + "server.getDatabaseStorage": { + "stream": true, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/ServerGetDatabaseStorageSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "server.getHostResources": { + "stream": false, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/ServerGetHostResourcesSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "server.getNpmRelease": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerGetNpmReleasePayload" + }, + "success": { + "$ref": "#/$defs/ServerGetNpmReleaseSuccess" + }, + "error": { + "$ref": "#/$defs/ServerGetNpmReleaseError" + } + }, + "server.getProcessDiagnostics": { + "stream": false, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/ServerGetProcessDiagnosticsSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "server.getProcessResourceHistory": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerGetProcessResourceHistoryPayload" + }, + "success": { + "$ref": "#/$defs/ServerGetProcessResourceHistorySuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "server.getResourceTelemetryHistory": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerGetProcessResourceHistoryPayload" + }, + "success": { + "$ref": "#/$defs/ServerGetResourceTelemetryHistorySuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "server.getStorageCleanupReport": { + "stream": true, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/ServerGetStorageCleanupReportSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "server.getTraceDiagnostics": { + "stream": false, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/ServerGetTraceDiagnosticsSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "server.getUsageSummary": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerGetUsageSummaryPayload" + }, + "success": { + "$ref": "#/$defs/ServerGetUsageSummarySuccess" + }, + "error": { + "$ref": "#/$defs/ServerGetUsageSummaryError" + } + }, + "server.importAcpRegistrySession": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerImportAcpRegistrySessionPayload" + }, + "success": { + "$ref": "#/$defs/ServerImportAcpRegistrySessionSuccess" + }, + "error": { + "$ref": "#/$defs/ServerDeleteAcpRegistrySessionError" + } + }, + "server.listAcpRegistryProviders": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerListAcpRegistryProvidersPayload" + }, + "success": { + "$ref": "#/$defs/ServerListAcpRegistryProvidersSuccess" + }, + "error": { + "$ref": "#/$defs/ServerDeleteAcpRegistrySessionError" + } + }, + "server.listAcpRegistrySessions": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerListAcpRegistrySessionsPayload" + }, + "success": { + "$ref": "#/$defs/ServerListAcpRegistrySessionsSuccess" + }, + "error": { + "$ref": "#/$defs/ServerDeleteAcpRegistrySessionError" + } + }, + "server.listOnePasswordAccounts": { + "stream": false, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/ServerListOnePasswordAccountsSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "server.logoutAcpRegistry": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProviderAuthLogoutPayload" + }, + "success": { + "$ref": "#/$defs/ServerLogoutAcpRegistrySuccess" + }, + "error": { + "$ref": "#/$defs/ServerDeleteAcpRegistrySessionError" + } + }, + "server.prepareAcpRegistryAgent": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerPrepareAcpRegistryAgentPayload" + }, + "success": { + "$ref": "#/$defs/ServerPrepareAcpRegistryAgentSuccess" + }, + "error": { + "$ref": "#/$defs/ServerDeleteAcpRegistrySessionError" + } + }, + "server.prepareTriage": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerPrepareTriagePayload" + }, + "success": { + "$ref": "#/$defs/ServerPrepareTriageSuccess" + }, + "error": { + "$ref": "#/$defs/ServerPrepareTriageError" + } + }, + "server.probe": { + "stream": false, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "server.refreshProviders": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerRefreshProvidersPayload" + }, + "success": { + "$ref": "#/$defs/ServerRefreshProvidersSuccess" + }, + "error": { + "$ref": "#/$defs/ServerRefreshProvidersError" + } + }, + "server.refreshUsageRates": { + "stream": false, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/Pricing" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "server.removeKeybinding": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerRemoveKeybindingPayload" + }, + "success": { + "$ref": "#/$defs/ServerRemoveKeybindingSuccess" + }, + "error": { + "$ref": "#/$defs/ServerRemoveKeybindingError" + } + }, + "server.reportClientActivity": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerReportClientActivityPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "server.retryResourceTelemetry": { + "stream": false, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/ServerRetryResourceTelemetrySuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "server.runStorageCleanup": { + "stream": false, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/Shape" + }, + "error": { + "$ref": "#/$defs/ServerRunStorageCleanupError" + } + }, + "server.searchAcpRegistry": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerSearchAcpRegistryPayload" + }, + "success": { + "$ref": "#/$defs/ServerSearchAcpRegistrySuccess" + }, + "error": { + "$ref": "#/$defs/ServerDeleteAcpRegistrySessionError" + } + }, + "server.setAcpRegistryProvider": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerSetAcpRegistryProviderPayload" + }, + "success": { + "$ref": "#/$defs/ServerSetAcpRegistryProviderSuccess" + }, + "error": { + "$ref": "#/$defs/ServerDeleteAcpRegistrySessionError" + } + }, + "server.signalProcess": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerSignalProcessPayload" + }, + "success": { + "$ref": "#/$defs/ServerSignalProcessSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "server.uninstallAcpRegistryManagedBinary": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerPrepareAcpRegistryAgentPayload" + }, + "success": { + "$ref": "#/$defs/ServerUninstallAcpRegistryManagedBinarySuccess" + }, + "error": { + "$ref": "#/$defs/ServerDeleteAcpRegistrySessionError" + } + }, + "server.updateProvider": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerUpdateProviderPayload" + }, + "success": { + "$ref": "#/$defs/ServerRefreshProvidersSuccess" + }, + "error": { + "$ref": "#/$defs/ServerUpdateProviderError" + } + }, + "server.updateServer": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerUpdateServerPayload" + }, + "success": { + "$ref": "#/$defs/ServerCommitDesktopUpdateSuccess" + }, + "error": { + "$ref": "#/$defs/ServerCommitDesktopUpdateError" + } + }, + "server.updateServerWithProgress": { + "stream": true, + "payload": { + "$ref": "#/$defs/ServerUpdateServerPayload" + }, + "success": { + "$ref": "#/$defs/ServerUpdateServerWithProgressSuccess" + }, + "error": { + "$ref": "#/$defs/ServerCommitDesktopUpdateError" + } + }, + "server.updateSettings": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerUpdateSettingsPayload" + }, + "success": { + "$ref": "#/$defs/Settings2" + }, + "error": { + "$ref": "#/$defs/ServerRunStorageCleanupError" + } + }, + "server.upsertKeybinding": { + "stream": false, + "payload": { + "$ref": "#/$defs/ServerUpsertKeybindingPayload" + }, + "success": { + "$ref": "#/$defs/ServerRemoveKeybindingSuccess" + }, + "error": { + "$ref": "#/$defs/ServerRemoveKeybindingError" + } + }, + "shell.openInEditor": { + "stream": false, + "payload": { + "$ref": "#/$defs/ShellOpenInEditorPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/ShellOpenInEditorError" + } + }, + "sourceControl.appAccess": { + "stream": false, + "payload": { + "$ref": "#/$defs/SourceControlAppAccessPayload" + }, + "success": { + "$ref": "#/$defs/SourceControlAppAccessSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "sourceControl.cloneRepository": { + "stream": false, + "payload": { + "$ref": "#/$defs/SourceControlCloneRepositoryPayload" + }, + "success": { + "$ref": "#/$defs/SourceControlCloneRepositorySuccess" + }, + "error": { + "$ref": "#/$defs/ProjectCloneRetryError" + } + }, + "sourceControl.lookupRepository": { + "stream": false, + "payload": { + "$ref": "#/$defs/SourceControlLookupRepositoryPayload" + }, + "success": { + "$ref": "#/$defs/Repository" + }, + "error": { + "$ref": "#/$defs/ProjectCloneRetryError" + } + }, + "sourceControl.publishRepository": { + "stream": false, + "payload": { + "$ref": "#/$defs/SourceControlPublishRepositoryPayload" + }, + "success": { + "$ref": "#/$defs/SourceControlPublishRepositorySuccess" + }, + "error": { + "$ref": "#/$defs/ProjectCloneRetryError" + } + }, + "sourceControl.signInCancel": { + "stream": false, + "payload": { + "$ref": "#/$defs/SourceControlSignInCancelPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/SourceControlSignInCancelError" + } + }, + "sourceControl.signInStart": { + "stream": false, + "payload": { + "$ref": "#/$defs/SourceControlSignInCancelPayload" + }, + "success": { + "$ref": "#/$defs/SourceControlSignInStartSuccess" + }, + "error": { + "$ref": "#/$defs/SourceControlSignInCancelError" + } + }, + "sourceControl.signInWait": { + "stream": false, + "payload": { + "$ref": "#/$defs/SourceControlSignInCancelPayload" + }, + "success": { + "$ref": "#/$defs/SourceControlSignInWaitSuccess" + }, + "error": { + "$ref": "#/$defs/SourceControlSignInCancelError" + } + }, + "sourceControl.star": { + "stream": false, + "payload": { + "$ref": "#/$defs/SourceControlAppAccessPayload" + }, + "success": { + "$ref": "#/$defs/SourceControlStarSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "sourceControl.starred": { + "stream": false, + "payload": { + "$ref": "#/$defs/SourceControlAppAccessPayload" + }, + "success": { + "$ref": "#/$defs/SourceControlStarSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "subscribeAuthAccess": { + "stream": true, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/SubscribeAuthAccessSuccess" + }, + "error": { + "$ref": "#/$defs/SubscribeAuthAccessError" + } + }, + "subscribeDeviceState": { + "stream": true, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/DeviceConfigureSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "subscribeDiscoveredLocalServers": { + "stream": true, + "payload": { + "$ref": "#/$defs/SubscribeDiscoveredLocalServersPayload" + }, + "success": { + "$ref": "#/$defs/SubscribeDiscoveredLocalServersSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "subscribePreviewEvents": { + "stream": true, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/SubscribePreviewEventsSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "subscribeProjectClones": { + "stream": true, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/SubscribeProjectClonesSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "subscribeResourceTelemetry": { + "stream": true, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/Snapshot2" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "subscribeServerConfig": { + "stream": true, + "payload": { + "$ref": "#/$defs/SubscribeServerConfigPayload" + }, + "success": { + "$ref": "#/$defs/SubscribeServerConfigSuccess" + }, + "error": { + "$ref": "#/$defs/ServerGetConfigError" + } + }, + "subscribeServerLifecycle": { + "stream": true, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/SubscribeServerLifecycleSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "subscribeTerminalMetadata": { + "stream": true, + "payload": { + "$ref": "#/$defs/AgentSessionsScanPayload" + }, + "success": { + "$ref": "#/$defs/SubscribeTerminalMetadataSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "subscribeVcsStatus": { + "stream": true, + "payload": { + "$ref": "#/$defs/SubscribeVcsStatusPayload" + }, + "success": { + "$ref": "#/$defs/SubscribeVcsStatusSuccess" + }, + "error": { + "$ref": "#/$defs/GitPreparePullRequestThreadError" + } + }, + "subscribeWorktreeSetup": { + "stream": true, + "payload": { + "$ref": "#/$defs/GuestShareCreatePayload" + }, + "success": { + "$ref": "#/$defs/SubscribeWorktreeSetupSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + }, + "terminal.attach": { + "stream": true, + "payload": { + "$ref": "#/$defs/TerminalAttachPayload" + }, + "success": { + "$ref": "#/$defs/TerminalAttachSuccess" + }, + "error": { + "$ref": "#/$defs/TerminalAttachError" + } + }, + "terminal.clear": { + "stream": false, + "payload": { + "$ref": "#/$defs/TerminalClearPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/TerminalAttachError" + } + }, + "terminal.close": { + "stream": false, + "payload": { + "$ref": "#/$defs/TerminalClosePayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/TerminalAttachError" + } + }, + "terminal.observe": { + "stream": true, + "payload": { + "$ref": "#/$defs/TerminalClearPayload" + }, + "success": { + "$ref": "#/$defs/TerminalAttachSuccess" + }, + "error": { + "$ref": "#/$defs/TerminalAttachError" + } + }, + "terminal.open": { + "stream": false, + "payload": { + "$ref": "#/$defs/TerminalOpenPayload" + }, + "success": { + "$ref": "#/$defs/Snapshot3" + }, + "error": { + "$ref": "#/$defs/TerminalAttachError" + } + }, + "terminal.resize": { + "stream": false, + "payload": { + "$ref": "#/$defs/TerminalResizePayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/TerminalAttachError" + } + }, + "terminal.write": { + "stream": false, + "payload": { + "$ref": "#/$defs/TerminalWritePayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/TerminalAttachError" + } + }, + "vcs.createRef": { + "stream": false, + "payload": { + "$ref": "#/$defs/VcsCreateRefPayload" + }, + "success": { + "$ref": "#/$defs/VcsCreateRefSuccess" + }, + "error": { + "$ref": "#/$defs/VcsCreateRefError" + } + }, + "vcs.createWorktree": { + "stream": false, + "payload": { + "$ref": "#/$defs/VcsCreateWorktreePayload" + }, + "success": { + "$ref": "#/$defs/VcsCreateWorktreeSuccess" + }, + "error": { + "$ref": "#/$defs/VcsCreateRefError" + } + }, + "vcs.init": { + "stream": false, + "payload": { + "$ref": "#/$defs/VcsInitPayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/VcsInitError" + } + }, + "vcs.listRefs": { + "stream": false, + "payload": { + "$ref": "#/$defs/VcsListRefsPayload" + }, + "success": { + "$ref": "#/$defs/VcsListRefsSuccess" + }, + "error": { + "$ref": "#/$defs/VcsCreateRefError" + } + }, + "vcs.pull": { + "stream": false, + "payload": { + "$ref": "#/$defs/ProjectsListIgnoredFilesPayload" + }, + "success": { + "$ref": "#/$defs/VcsPullSuccess" + }, + "error": { + "$ref": "#/$defs/VcsCreateRefError" + } + }, + "vcs.refreshStatus": { + "stream": false, + "payload": { + "$ref": "#/$defs/VcsRefreshStatusPayload" + }, + "success": { + "$ref": "#/$defs/VcsRefreshStatusSuccess" + }, + "error": { + "$ref": "#/$defs/GitPreparePullRequestThreadError" + } + }, + "vcs.removeWorktree": { + "stream": false, + "payload": { + "$ref": "#/$defs/VcsRemoveWorktreePayload" + }, + "success": { + "$ref": "#/$defs/AttachmentsDeleteSuccess" + }, + "error": { + "$ref": "#/$defs/VcsCreateRefError" + } + }, + "vcs.switchRef": { + "stream": false, + "payload": { + "$ref": "#/$defs/VcsSwitchRefPayload" + }, + "success": { + "$ref": "#/$defs/VcsSwitchRefSuccess" + }, + "error": { + "$ref": "#/$defs/VcsCreateRefError" + } + }, + "worktreeSetup.cancel": { + "stream": false, + "payload": { + "$ref": "#/$defs/GuestShareCreatePayload" + }, + "success": { + "$ref": "#/$defs/WorktreeSetupCancelSuccess" + }, + "error": { + "$ref": "#/$defs/EnvironmentAuthorizationErrorEncoded" + } + } + } +} diff --git a/packages/contracts/scripts/export-rpc-json-schema.ts b/packages/contracts/scripts/export-rpc-json-schema.ts new file mode 100644 index 000000000000..d75df877b57f --- /dev/null +++ b/packages/contracts/scripts/export-rpc-json-schema.ts @@ -0,0 +1,316 @@ +// @effect-diagnostics nodeBuiltinImport:off - a codegen script that reads and writes one file. +// Writes schema/rpc.schema.json: every WsRpcGroup method's payload, success and error schema as +// one JSON Schema document with shared $defs, for clients that are not TypeScript (the GPUI +// desktop generates its Rust wire types from it). It describes the encoded (wire) side. +// +// vp run --filter @t3tools/contracts generate rewrite the file +// vp run --filter @t3tools/contracts generate --check fail when the file is stale +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; + +import * as Schema from "effect/Schema"; +import { RpcSchema } from "effect/rpc"; + +import { WsRpcGroup } from "../src/rpc.ts"; + +type Json = + | null + | boolean + | number + | string + | ReadonlyArray + | { readonly [key: string]: Json }; +type JsonObject = { [key: string]: Json }; + +const REF_PREFIX = "#/$defs/"; +// Repeated subtrees smaller than this stay inline: a $ref would cost about as much as the copy. +const MIN_HOIST_BYTES = 200; + +export const SCHEMA_PATH = NodePath.join( + NodeURL.fileURLToPath(new URL("..", import.meta.url)), + "schema", + "rpc.schema.json", +); + +const isObject = (value: Json): value is JsonObject => + typeof value === "object" && value !== null && !Array.isArray(value); + +const pascal = (text: string) => + text + .split(/[^A-Za-z0-9]+/) + .filter(Boolean) + .map((word) => word[0]!.toUpperCase() + word.slice(1)) + .join(""); + +const renameRefs = (node: Json, renames: ReadonlyMap): Json => { + if (Array.isArray(node)) return node.map((child) => renameRefs(child, renames)); + if (!isObject(node)) return node; + const out: JsonObject = {}; + for (const [key, child] of Object.entries(node)) { + out[key] = + key === "$ref" && typeof child === "string" && child.startsWith(REF_PREFIX) + ? REF_PREFIX + + (renames.get(child.slice(REF_PREFIX.length)) ?? child.slice(REF_PREFIX.length)) + : renameRefs(child, renames); + } + return out; +}; + +interface Root { + readonly tag: string; + readonly part: "payload" | "success" | "error"; + readonly schema: Schema.Top; +} + +const rootsOf = () => { + const methods: Array<{ tag: string; stream: boolean; roots: ReadonlyArray }> = []; + for (const rpc of [...WsRpcGroup.requests.values()].sort((a, b) => + a._tag.localeCompare(b._tag), + )) { + const stream = RpcSchema.isStreamSchema(rpc.successSchema); + // A stream method's success is the element schema and its error the stream error; the rpc's + // own error schema is Never. + const success = RpcSchema.isStreamSchema(rpc.successSchema) + ? rpc.successSchema.success + : rpc.successSchema; + const error = RpcSchema.isStreamSchema(rpc.successSchema) + ? rpc.successSchema.error + : rpc.errorSchema; + methods.push({ + tag: rpc._tag, + stream, + roots: [ + { tag: rpc._tag, part: "payload", schema: rpc.payloadSchema as Schema.Top }, + { tag: rpc._tag, part: "success", schema: success as Schema.Top }, + { tag: rpc._tag, part: "error", schema: error as Schema.Top }, + ], + }); + } + return methods; +}; + +const NULL_SCHEMA = '{"type":"null"}'; +const canon = (node: Json) => JSON.stringify(node); +const isBareRef = (node: Json) => + isObject(node) && typeof node.$ref === "string" && Object.keys(node).length === 1; +// Optional-and-nullable fields encode as an anyOf that repeats `null`. +const dedupeAlternatives = (node: Json): Json => { + if (Array.isArray(node)) return node.map(dedupeAlternatives); + if (!isObject(node)) return node; + const out = Object.fromEntries( + Object.entries(node).map(([key, child]) => [key, dedupeAlternatives(child)]), + ); + if (Array.isArray(out.anyOf)) { + const seen = new Set(); + out.anyOf = out.anyOf.filter( + (alternative) => !seen.has(canon(alternative)) && seen.add(canon(alternative)), + ); + } + return out; +}; +const refTo = (name: string): Json => ({ $ref: REF_PREFIX + name }); + +export const buildRpcSchemaDocument = () => { + const methods = rootsOf(); + const defs = new Map(); + + // Named definitions come from identifier annotations. Two contracts can reuse one identifier + // for different shapes, so a clash gets a numeric suffix and that schema's references follow. + const rawShapes = new Map(); + const trees = methods.map((method) => ({ + method, + parts: method.roots.map((root) => { + const document = Schema.toJsonSchemaDocument(root.schema) as unknown as { + schema: Json; + definitions: Record; + }; + const renames = new Map(); + const entries = Object.entries(document.definitions); + for (const [name, value] of entries) { + let candidate = name; + for ( + let n = 2; + rawShapes.has(candidate) && rawShapes.get(candidate) !== canon(value); + n++ + ) { + candidate = `${name}${n}`; + } + if (candidate !== name) renames.set(name, candidate); + rawShapes.set(candidate, canon(value)); + } + for (const [name, value] of entries) { + defs.set(renames.get(name) ?? name, dedupeAlternatives(renameRefs(value, renames))); + } + return { root, schema: dedupeAlternatives(renameRefs(document.schema, renames)) }; + }), + })); + + // Shapes that occur more than once across the whole forest become shared definitions, named + // after the property they most often sit under (`delegatedFrom` -> DelegatedFrom, an array's + // elements -> ThreadsEntry), so a name does not depend on which method came first. + const counts = new Map(); + const contexts = new Map>(); + const tally = (node: Json, context: string) => { + if (Array.isArray(node)) return node.forEach((child) => tally(child, context)); + if (!isObject(node)) return; + const key = canon(node); + if (key.length >= MIN_HOIST_BYTES) { + counts.set(key, (counts.get(key) ?? 0) + 1); + if (context) { + const seen = contexts.get(key) ?? new Map(); + seen.set(context, (seen.get(context) ?? 0) + 1); + contexts.set(key, seen); + } + } + for (const [child, value] of Object.entries(node)) { + if (child === "properties" && isObject(value)) { + Object.entries(value).forEach(([prop, schema]) => tally(schema, pascal(prop))); + } else { + tally(value, child === "items" ? `${context}Entry` : context); + } + } + }; + trees.forEach(({ parts }) => parts.forEach(({ schema }) => tally(schema, ""))); + defs.forEach((body, name) => tally(body, name)); + const nameFor = (key: string) => + [...(contexts.get(key) ?? [["Shape", 0]])].sort( + ([a, x], [b, y]) => y - x || a.localeCompare(b), + )[0]![0]; + + const taken = new Set(defs.keys()); + const claim = (base: string) => { + let name = base; + for (let n = 2; taken.has(name); n++) name = `${base}${n}`; + taken.add(name); + return name; + }; + const byShape = new Map(); + const shared = new Map(); + const inner = new Set(); + + const children = (node: JsonObject): JsonObject => + Object.fromEntries( + Object.entries(node).map(([key, child]) => [ + key, + key === "properties" && isObject(child) + ? Object.fromEntries(Object.entries(child).map(([prop, value]) => [prop, rewrite(value)])) + : rewrite(child), + ]), + ); + // The outermost repeated shape wins. Its body is rewritten once, so shapes repeated only + // inside copies of it are not hoisted separately (unused leftovers are inlined below). + const rewrite = (node: Json): Json => { + if (Array.isArray(node)) return node.map(rewrite); + if (!isObject(node) || isBareRef(node)) return node; + const key = canon(node); + // typify names the payload of a nullable definition after the definition, so hoist only the + // non-null shape and keep the null alternative inline. + const nullable = + Array.isArray(node.anyOf) && + node.anyOf.some((alternative) => canon(alternative) === NULL_SCHEMA); + if (nullable || (counts.get(key) ?? 0) < 2) return children(node); + let name = byShape.get(key); + if (!name) { + name = claim(nameFor(key)); + byShape.set(key, name); + inner.add(name); + shared.set(name, children(node)); + } + return refTo(name); + }; + + const index: Record = {}; + for (const { method, parts } of trees) { + const entry: JsonObject = { stream: method.stream }; + for (const { root, schema } of parts) { + let reference = schema; + if (!isBareRef(schema)) { + // The same shape under two methods (a void payload, a shared error union) shares one def. + const key = canon(schema); + let name = byShape.get(key); + if (!name) { + name = claim(`${pascal(root.tag)}${pascal(root.part)}`); + byShape.set(key, name); + shared.set(name, children(schema as JsonObject)); + } + inner.delete(name); + reference = refTo(name); + } + entry[root.part] = reference; + } + index[method.tag] = entry; + } + for (const [name, body] of defs) { + defs.set(name, isObject(body) ? children(body) : body); + } + for (const [name, body] of shared) defs.set(name, body); + + // A hoisted inner shape used once, from a single definition, reads better inline. + const inline = (node: Json, uses: ReadonlyMap): Json => { + if (Array.isArray(node)) return node.map((child) => inline(child, uses)); + if (!isObject(node)) return node; + const ref = node.$ref; + if (typeof ref === "string" && isBareRef(node)) { + const name = ref.slice(REF_PREFIX.length); + if (inner.has(name) && uses.get(name) === 1) return inline(defs.get(name)!, uses); + return node; + } + return Object.fromEntries( + Object.entries(node).map(([key, child]) => [key, inline(child, uses)]), + ); + }; + for (;;) { + const uses = new Map(); + const scan = (node: Json) => { + if (Array.isArray(node)) return node.forEach(scan); + if (!isObject(node)) return; + if (isBareRef(node)) { + const name = (node.$ref as string).slice(REF_PREFIX.length); + uses.set(name, (uses.get(name) ?? 0) + 1); + return; + } + Object.values(node).forEach(scan); + }; + defs.forEach(scan); + Object.values(index).forEach(scan); + const drop = [...inner].filter((name) => (uses.get(name) ?? 0) <= 1); + if (drop.length === 0) break; + const rewritten = new Map([...defs].map(([name, body]) => [name, inline(body, uses)] as const)); + for (const name of drop) { + rewritten.delete(name); + inner.delete(name); + } + defs.clear(); + rewritten.forEach((body, name) => defs.set(name, body)); + } + + const sorted: JsonObject = {}; + for (const name of [...defs.keys()].sort()) sorted[name] = defs.get(name)!; + return { + $schema: "https://json-schema.org/draft/2020-12/schema", + title: "T3 Code WebSocket RPC (encoded)", + $defs: sorted, + methods: index, + }; +}; + +export const renderRpcSchemaDocument = () => + JSON.stringify(buildRpcSchemaDocument(), null, 1) + "\n"; + +if (import.meta.main) { + const next = renderRpcSchemaDocument(); + if (process.argv.includes("--check")) { + const current = NodeFS.existsSync(SCHEMA_PATH) ? NodeFS.readFileSync(SCHEMA_PATH, "utf8") : ""; + if (current !== next) { + console.error( + "packages/contracts/schema/rpc.schema.json is stale. Run `vp run --filter @t3tools/contracts generate` and commit it.", + ); + process.exitCode = 1; + } + } else { + NodeFS.mkdirSync(NodePath.dirname(SCHEMA_PATH), { recursive: true }); + NodeFS.writeFileSync(SCHEMA_PATH, next); + console.log(`wrote ${NodePath.relative(process.cwd(), SCHEMA_PATH)} (${next.length} bytes)`); + } +} diff --git a/scripts/check-changed.ts b/scripts/check-changed.ts index c5d7979df580..8e10ad5762ec 100644 --- a/scripts/check-changed.ts +++ b/scripts/check-changed.ts @@ -460,6 +460,8 @@ const checkChanged = Effect.fn("checkChanged")(function* (input: { const graphql = ["apps/server", "packages/source-control-github"].filter((dir) => files.some((file) => file.startsWith(`${dir}/`)), ); + // CI's Lint job also checks the RPC JSON Schema that the GPUI desktop generates its wire types from. + const rpcSchema = files.some((file) => file.startsWith("packages/contracts/")); const filters = (names: ReadonlyArray) => names.flatMap((name) => ["--filter", name]); const typecheckWorkers = Math.min(concurrency, affected.length); const command = (name: string, args: ReadonlyArray) => ({ name, run: vp(args) }); @@ -479,6 +481,9 @@ const checkChanged = Effect.fn("checkChanged")(function* (input: { ...graphql.map((dir) => command(`graphql (${dir})`, ["run", "--filter", `./${dir}`, "generate", "--check"]), ), + ...(rpcSchema + ? [command("rpc schema", ["run", "--filter", "@t3tools/contracts", "generate", "--check"])] + : []), ...(affected.length > 0 ? [ { diff --git a/vite.config.ts b/vite.config.ts index c92ad24843f9..88f8cffdb891 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -169,6 +169,7 @@ export default defineConfig({ "*.tsbuildinfo", "**/routeTree.gen.ts", "**/*.graphql.ts", + "packages/contracts/schema/**", "packages/source-control-github/schema/**", "apps/server/schema/**", "apps/mobile/android/**", @@ -201,6 +202,7 @@ export default defineConfig({ "*.tsbuildinfo", "**/routeTree.gen.ts", "**/*.graphql.ts", + "packages/contracts/schema/**", "packages/source-control-github/schema/**", "apps/server/schema/**", "apps/mobile/android/**", From fc3bad40940aebfc2c9eaea0fd93b84b27826310 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Wed, 4 Nov 2026 23:58:51 -0500 Subject: [PATCH 03/82] feat(desktop-gpui): attach to an already running server with a pairing token Spawning a second server on a home that another server already owns would race it on the same database. Attaching with a pairing token lets the native client show a running server's data, and it is the same path remote and tunnel servers will need. Signed-off-by: Yordis Prieto --- apps/desktop-gpui/README.md | 7 +++++ apps/desktop-gpui/src/auth.rs | 22 ++++++++++++--- apps/desktop-gpui/src/config.rs | 42 +++++++++++++++++++++++++++++ apps/desktop-gpui/src/connection.rs | 33 ++++++++++++++--------- apps/desktop-gpui/src/main.rs | 30 ++++++++++++++++++--- apps/desktop-gpui/src/server.rs | 5 ++-- 6 files changed, 118 insertions(+), 21 deletions(-) diff --git a/apps/desktop-gpui/README.md b/apps/desktop-gpui/README.md index 4262d1e05ad1..72d22c08abd3 100644 --- a/apps/desktop-gpui/README.md +++ b/apps/desktop-gpui/README.md @@ -23,6 +23,13 @@ The app reads these variables, which the dev runner sets. Without them it shows | `T3CODE_GPUI_SERVER_COMMAND` | Program that starts the server (`node` in dev, `t3` once packaged). | | `T3CODE_GPUI_SERVER_ENTRY` | Optional first argument, the server entry script in dev. | +To see real data, attach to a server that is already running (for example your `vp run dev:desktop`) instead of starting one. That server keeps sole ownership of its database. Mint a one-time pairing token with `node apps/server/src/bin.ts pair --base-dir `, then run the app with: + +| Variable | Meaning | +| --------------------------- | --------------------------------------------------------- | +| `T3CODE_GPUI_SERVER_URL` | HTTP origin of the running server. Takes precedence. | +| `T3CODE_GPUI_PAIRING_TOKEN` | The `Token:` value printed by `pair`. It works only once. | + Other scripts: ```sh diff --git a/apps/desktop-gpui/src/auth.rs b/apps/desktop-gpui/src/auth.rs index 618ab7ffca6f..20ad54b2b85a 100644 --- a/apps/desktop-gpui/src/auth.rs +++ b/apps/desktop-gpui/src/auth.rs @@ -79,13 +79,29 @@ struct TicketResult { ticket: String, } -/// RFC 8693 exchange of the desktop bootstrap token for a bearer session. +/// What the app proves itself with: the secret it handed a server it spawned, +/// or a pairing token minted by `t3 pair` for a server it attaches to. +pub enum Credential { + DesktopSecret(String), + Pairing(String), +} + +impl Credential { + fn subject_token(&self) -> String { + match self { + Self::DesktopSecret(secret) => bootstrap_token(secret, now_ms()), + Self::Pairing(token) => token.clone(), + } + } +} + +/// RFC 8693 exchange of a bootstrap credential for a bearer session. pub async fn exchange_bearer( client: &reqwest::Client, http_base: &str, - secret: &str, + credential: &Credential, ) -> Result { - let credential = bootstrap_token(secret, now_ms()); + let credential = credential.subject_token(); let response = client .post(format!("{http_base}/oauth/token")) .form(&[ diff --git a/apps/desktop-gpui/src/config.rs b/apps/desktop-gpui/src/config.rs index 1ff8afe8ce68..d9bf28e9233b 100644 --- a/apps/desktop-gpui/src/config.rs +++ b/apps/desktop-gpui/src/config.rs @@ -4,6 +4,34 @@ pub const ENV_PORT: &str = "T3CODE_GPUI_SERVER_PORT"; pub const ENV_HOME: &str = "T3CODE_GPUI_HOME"; pub const ENV_COMMAND: &str = "T3CODE_GPUI_SERVER_COMMAND"; pub const ENV_ENTRY: &str = "T3CODE_GPUI_SERVER_ENTRY"; +pub const ENV_ATTACH_URL: &str = "T3CODE_GPUI_SERVER_URL"; +pub const ENV_PAIRING_TOKEN: &str = "T3CODE_GPUI_PAIRING_TOKEN"; + +/// A server someone else runs (a dev server, `npx t3`, a remote host), reached +/// with a pairing token instead of being spawned. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Attach { + pub http_base: String, + pub pairing_token: String, +} + +impl Attach { + pub fn from_env() -> Option { + Self::from_lookup(|name| std::env::var(name).ok()) + } + + fn from_lookup(get: impl Fn(&str) -> Option) -> Option { + let non_empty = |name| { + get(name) + .map(|v| v.trim().to_owned()) + .filter(|v| !v.is_empty()) + }; + Some(Self { + http_base: non_empty(ENV_ATTACH_URL)?.trim_end_matches('/').to_owned(), + pairing_token: non_empty(ENV_PAIRING_TOKEN)?, + }) + } +} /// How to start the server. The command is data: dev runs `node `, a /// packaged build runs `t3` from PATH with no entry. @@ -83,4 +111,18 @@ mod tests { assert_eq!(Config::from_lookup(lookup(&[(ENV_PORT, "1")])), None); assert_eq!(Config::from_lookup(lookup(&[])), None); } + + #[test] + fn attach_needs_url_and_token() { + let attach = Attach::from_lookup(lookup(&[ + (ENV_ATTACH_URL, "http://127.0.0.1:13773/"), + (ENV_PAIRING_TOKEN, "tok"), + ])) + .expect("attach"); + assert_eq!(attach.http_base, "http://127.0.0.1:13773"); + assert_eq!( + Attach::from_lookup(lookup(&[(ENV_ATTACH_URL, "http://h")])), + None + ); + } } diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 60f2dbd9eb47..c6c5e658f6e2 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -1,7 +1,6 @@ //! The authenticated RPC WebSocket: connect, keep alive, reconnect. -use crate::auth::{self, AuthError}; -use crate::config::Config; +use crate::auth::{self, AuthError, Credential}; use crate::rpc::{Event, PING_FRAME, Session, SubscribeShell}; use crate::shell::ShellState; use crate::status::{Counts, Status, StatusSender, publish}; @@ -43,9 +42,18 @@ impl Backoff { } } -fn socket_url(port: u16, ticket: &str) -> Result { +fn socket_url(http_base: &str, ticket: &str) -> Result { + let ws_base = http_base + .strip_prefix("https://") + .map(|rest| format!("wss://{rest}")) + .or_else(|| { + http_base + .strip_prefix("http://") + .map(|rest| format!("ws://{rest}")) + }) + .ok_or_else(|| format!("unsupported server URL: {http_base}"))?; reqwest::Url::parse_with_params( - &format!("ws://127.0.0.1:{port}/ws"), + &format!("{}/ws", ws_base.trim_end_matches('/')), [ ("orchestrationProtocol", ORCHESTRATION_PROTOCOL_VERSION), ("wsTicket", ticket), @@ -59,14 +67,13 @@ fn socket_url(port: u16, ticket: &str) -> Result { } /// Connects and stays connected until cancelled, reporting through `status`. -pub async fn run(config: &Config, secret: &str, label: &str, status: &StatusSender) { +pub async fn run(http_base: &str, credential: &Credential, label: &str, status: &StatusSender) { let client = auth::http_client(); - let http_base = config.http_base(); let mut bearer: Option = None; let mut backoff = Backoff::new(); loop { publish(status, Status::Connecting); - let failure = match connect(&client, config, &http_base, secret, &mut bearer).await { + let failure = match connect(&client, http_base, credential, &mut bearer).await { Ok(socket) => { publish( status, @@ -90,15 +97,14 @@ type Socket = async fn connect( client: &reqwest::Client, - config: &Config, http_base: &str, - secret: &str, + credential: &Credential, bearer: &mut Option, ) -> Result { let token = match bearer { Some(token) => token.clone(), None => { - let token = auth::exchange_bearer(client, http_base, secret) + let token = auth::exchange_bearer(client, http_base, credential) .await .map_err(|error| error.to_string())?; *bearer = Some(token.clone()); @@ -114,7 +120,7 @@ async fn connect( return Err(error.to_string()); } }; - let url = socket_url(config.port, &ticket)?; + let url = socket_url(http_base, &ticket)?; let (socket, _) = tokio_tungstenite::connect_async(url.as_str()) .await .map_err(|error| error.to_string())?; @@ -223,9 +229,12 @@ mod tests { #[test] fn socket_url_carries_protocol_and_escaped_ticket() { - let url = socket_url(13773, "a b&c").expect("url"); + let url = socket_url("http://127.0.0.1:13773", "a b&c").expect("url"); assert_eq!(url.scheme(), "ws"); assert_eq!(url.path(), "/ws"); + let remote = socket_url("https://host.example/", "t").expect("url"); + assert_eq!(remote.scheme(), "wss"); + assert_eq!(remote.path(), "/ws"); let pairs: Vec<_> = url.query_pairs().into_owned().collect(); assert!(pairs.contains(&("orchestrationProtocol".into(), "2".into()))); assert!(pairs.contains(&("wsTicket".into(), "a b&c".into()))); diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index c3cbc50c93bb..3ee9c7d1f861 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -7,7 +7,7 @@ mod shell; mod status; mod wire; -use config::Config; +use config::{Attach, Config}; use gpui::{ App, Bounds, Context, SharedString, TitlebarOptions, Window, WindowBounds, WindowOptions, div, prelude::*, px, rgb, size, @@ -42,9 +42,31 @@ fn main() { .build() .expect("failed to start the async runtime"); let (status_tx, status_rx) = async_channel::unbounded(); - let supervisor = Config::from_env() - .map(|config| Rc::new(Supervisor::start(runtime.handle(), config, status_tx))); - let initial = if supervisor.is_some() { + let attach = Attach::from_env(); + let supervisor = if attach.is_some() { + None + } else { + Config::from_env().map(|config| { + Rc::new(Supervisor::start( + runtime.handle(), + config, + status_tx.clone(), + )) + }) + }; + let initial = if let Some(attach) = attach { + runtime.spawn(async move { + let client = auth::http_client(); + match server::wait_until_ready(&client, &attach.http_base).await { + Ok(label) => { + let credential = auth::Credential::Pairing(attach.pairing_token); + connection::run(&attach.http_base, &credential, &label, &status_tx).await; + } + Err(message) => status::publish(&status_tx, Status::Failed(message)), + } + }); + Status::Connecting + } else if supervisor.is_some() { Status::Starting } else { Status::NotConnected diff --git a/apps/desktop-gpui/src/server.rs b/apps/desktop-gpui/src/server.rs index 3d4873b2c3ba..e96963438d94 100644 --- a/apps/desktop-gpui/src/server.rs +++ b/apps/desktop-gpui/src/server.rs @@ -165,7 +165,7 @@ struct Descriptor { } /// Polls until the descriptor answers, returning the environment label. -async fn wait_until_ready(client: &reqwest::Client, http_base: &str) -> Result { +pub async fn wait_until_ready(client: &reqwest::Client, http_base: &str) -> Result { let url = format!("{http_base}{READINESS_PATH}"); let deadline = Instant::now() + READINESS_TIMEOUT; loop { @@ -218,7 +218,8 @@ async fn session(config: &Config, secret: &str, status: &StatusSender) -> String let client = auth::http_client(); match wait_until_ready(&client, &config.http_base()).await { Ok(label) => { - connection::run(config, secret, &label, status).await; + let credential = auth::Credential::DesktopSecret(secret.to_owned()); + connection::run(&config.http_base(), &credential, &label, status).await; unreachable_session() } Err(message) => message, From 7c525bcfbd2399edacfdd1b7fc368d58e7a7d8c6 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 5 Nov 2026 00:20:18 -0500 Subject: [PATCH 04/82] feat(desktop-gpui): render the main screen skeleton with real threads A status line can't show whether the generated types hold up against real data. gpui-component supplies native inputs, buttons and theming, so the sidebar, empty state and composer render without a webview. That requires the crates.io gpui-pre build it targets. Signed-off-by: Yordis Prieto --- apps/desktop-gpui/Cargo.lock | 2201 ++++++++++++++++++++++----- apps/desktop-gpui/Cargo.toml | 8 +- apps/desktop-gpui/README.md | 4 +- apps/desktop-gpui/src/connection.rs | 9 +- apps/desktop-gpui/src/main.rs | 54 +- apps/desktop-gpui/src/shell.rs | 56 + apps/desktop-gpui/src/status.rs | 39 +- apps/desktop-gpui/src/ui.rs | 235 +++ 8 files changed, 2187 insertions(+), 419 deletions(-) create mode 100644 apps/desktop-gpui/src/ui.rs diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index d2c4c67b10c2..1e3e164a6a4d 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -119,6 +119,23 @@ dependencies = [ "libc", ] +[[package]] +name = "annotate-snippets" +version = "0.12.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a5b0a5ab8561c603f16c3b78c24a386b1f70e7fc1b1b1e8c15738828e4a176" +dependencies = [ + "anstyle", + "memchr", + "unicode-width", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "anyhow" version = "1.0.104" @@ -131,6 +148,15 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" +[[package]] +name = "arc-swap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "380962b3d93b99642e50b9febf72c071e97e8b32d86eae9cf4ef582bb960ae6e" +dependencies = [ + "rustversion", +] + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -142,6 +168,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + [[package]] name = "arrayref" version = "0.3.9" @@ -363,9 +395,9 @@ dependencies = [ "log", "num-rational", "num-traits", - "pastey 0.1.1", + "pastey", "rayon", - "thiserror", + "thiserror 2.0.21", "v_frame", "y4m", ] @@ -431,6 +463,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "base62" +version = "2.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3a647dce9631f152a99ec16cfc09267ae55fe280d5dc288964027d458076323" + [[package]] name = "base64" version = "0.22.1" @@ -445,18 +483,18 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bit-set" -version = "0.11.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" -version = "0.10.1" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" [[package]] name = "bit_field" @@ -485,6 +523,12 @@ dependencies = [ "no_std_io2", ] +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + [[package]] name = "block-buffer" version = "0.10.4" @@ -543,6 +587,17 @@ dependencies = [ "piper", ] +[[package]] +name = "bstr" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "built" version = "0.8.1" @@ -707,6 +762,36 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation 0.9.4", + "core-graphics 0.23.2", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "libc", + "objc", +] + [[package]] name = "codespace" version = "0.0.1-alpha.3" @@ -731,16 +816,6 @@ dependencies = [ "unicode-width", ] -[[package]] -name = "collections" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" -dependencies = [ - "gpui_util", - "indexmap", - "rustc-hash 2.1.3", -] - [[package]] name = "color_quant" version = "1.1.0" @@ -791,6 +866,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -807,6 +892,19 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", + "foreign-types", + "libc", +] + [[package]] name = "core-graphics" version = "0.24.0" @@ -814,12 +912,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1" dependencies = [ "bitflags 2.13.2", - "core-foundation", - "core-graphics-types", + "core-foundation 0.10.1", + "core-graphics-types 0.2.0", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-helmer-fork" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32eb7c354ae9f6d437a6039099ce7ecd049337a8109b23d73e48e8ffba8e9cd5" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "core-graphics-types 0.1.3", "foreign-types", "libc", ] +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "libc", +] + [[package]] name = "core-graphics-types" version = "0.2.0" @@ -827,7 +949,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -837,12 +959,18 @@ version = "21.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a593227b66cbd4007b2a050dfdd9e1d1318311409c8d600dc82ba1b15ca9c130" dependencies = [ - "core-foundation", - "core-graphics", + "core-foundation 0.10.1", + "core-graphics 0.24.0", "foreign-types", "libc", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "core_maths" version = "0.1.1" @@ -1012,16 +1140,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "derive_refineable" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "digest" version = "0.10.7" @@ -1045,13 +1163,34 @@ dependencies = [ "ctutils", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -1062,10 +1201,16 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.3", "windows-sys 0.61.2", ] +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + [[package]] name = "dispatch2" version = "0.3.1" @@ -1150,12 +1295,55 @@ dependencies = [ "winreg", ] +[[package]] +name = "encoding_rs" +version = "0.8.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", +] + [[package]] name = "endi" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum-iterator" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4549325971814bda7a44061bf3fe7e487d447cba01e4220a4b454d630d7a016" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -1336,6 +1524,15 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" +[[package]] +name = "fluent-uri" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17c704e9dbe1ddd863da1e6ff3567795087b1eb201ce80d8fa81162e1516500d" +dependencies = [ + "bitflags 1.3.2", +] + [[package]] name = "flume" version = "0.12.0" @@ -1347,6 +1544,12 @@ dependencies = [ "spin 0.9.9", ] +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.2.0" @@ -1447,6 +1650,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.34" @@ -1599,6 +1812,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gif" version = "0.14.2" @@ -1626,11 +1849,41 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "globset" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98b0d85522dc0a824cfebbc9594ff4a9b289c568bea0370319dfbdf20ccdd2d" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "globwalk" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93e3af942408868f6934a7b85134a3230832b9977cf66125df2f9edcfce4ddcc" +dependencies = [ + "bitflags 1.3.2", + "ignore", + "walkdir", +] + [[package]] name = "glow" -version = "0.18.0" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "263218b0ba2b0715c3370fb04674f5f8aa331594b521c94ff0a8e4a8472d1386" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" dependencies = [ "js-sys", "slotmap", @@ -1657,94 +1910,211 @@ dependencies = [ "hashbrown 0.16.1", "log", "presser", - "thiserror", + "thiserror 2.0.21", "windows 0.62.2", ] [[package]] -name = "gpui" -version = "0.2.2" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +name = "gpui-base" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff6377e30f0c857681662e7edc7286bf30f5606033390401b0706d7bd930833" dependencies = [ - "accesskit", + "aho-corasick", "anyhow", + "ashpd", "async-channel", - "async-task", - "backtrace", - "bitflags 2.13.2", "chrono", - "collections", - "derive_more", - "embed-resource", - "etagere", + "data-url", "futures", - "getrandom 0.3.4", - "gpui_macros", - "gpui_shared_string", - "gpui_util", - "heapless", - "http_client", - "icu_segmenter", - "image", - "inventory", - "itertools", - "libc", - "log", - "lyon", - "num_cpus", - "objc2-core-foundation", - "objc2-core-video", - "parking", - "parking_lot", - "pin-project", - "pollster", - "profiling", - "rand 0.9.5", + "gpui-pre", + "gpui-pre-macros", + "gpui-pre-sum-tree", + "html5ever", + "instant", + "itertools 0.13.0", + "lsp-types", + "markdown", + "markup5ever_rcdom", + "num-traits", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", "raw-window-handle", - "refineable", "regex", - "resvg", - "scheduler", + "ropey", "schemars 1.2.3", - "seahash", "serde", "serde_json", - "slotmap", "smallvec", - "spin 0.10.1", - "strum", - "sum_tree", - "taffy", - "thiserror", - "ttf-parser", + "smol", + "tracing", + "unicode-linebreak", "unicode-segmentation", - "url", - "usvg", - "uuid", - "waker-fn", - "windows 0.62.2", - "zed-scap", + "web-time", + "windows 0.58.0", ] [[package]] -name = "gpui_apple" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" -dependencies = [ +name = "gpui-component" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45bbfdba0efa392190f2fd446ec311325302eb047d5ce45dc1bcf189cb18f5eb" +dependencies = [ + "anyhow", + "chrono", + "core-text", + "enum-iterator", + "gpui-base", + "gpui-component-macros", + "gpui-kit-assets", + "gpui-pre", + "gpui-pre-macros", + "gpui-pre-sum-tree", + "instant", + "itertools 0.13.0", + "log", + "lsp-types", + "markdown", + "notify", + "num-traits", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "once_cell", + "paste", + "raw-window-handle", + "regex", + "resvg 0.45.1", + "ropey", + "rust-i18n", + "schemars 1.2.3", + "serde", + "serde_json", + "serde_repr", + "similar", + "smallvec", + "smol", + "tracing", + "unicode-segmentation", + "unicode-width", + "uuid", + "windows 0.58.0", +] + +[[package]] +name = "gpui-component-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21ac1d2ca5352973e291b3316c027e603b1e7334755341f44c8231f85fde627a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-kit-assets" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "956a9864880e1f230e274a74cea8006e5018b8af383ad7bbd30b6b18254a97a9" +dependencies = [ + "anyhow", + "gpui-pre", + "gpui-pre-reqwest", + "log", + "rust-embed", + "wasm-bindgen", + "wasm-bindgen-futures", +] + +[[package]] +name = "gpui-pre" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9303c771abf2acff2a96d706f69d9a82088c4770173a07cce88f0002431d887d" +dependencies = [ + "accesskit", + "anyhow", + "async-channel", + "async-task", + "backtrace", + "bitflags 2.13.2", + "chrono", + "derive_more", + "embed-resource", + "etagere", + "futures", + "getrandom 0.3.4", + "gpui-pre-collections", + "gpui-pre-http-client", + "gpui-pre-macros", + "gpui-pre-refineable", + "gpui-pre-scheduler", + "gpui-pre-shared-string", + "gpui-pre-sum-tree", + "gpui-pre-util", + "heapless", + "icu_segmenter", + "image", + "inventory", + "itertools 0.14.0", + "libc", + "log", + "lyon", + "num_cpus", + "objc2-core-foundation", + "objc2-core-video", + "parking", + "parking_lot", + "pin-project", + "pollster", + "profiling", + "rand 0.9.5", + "raw-window-handle", + "regex", + "resvg 0.46.0", + "schemars 1.2.3", + "seahash", + "serde", + "serde_json", + "slotmap", + "smallvec", + "spin 0.10.1", + "strum", + "taffy", + "thiserror 2.0.21", + "ttf-parser", + "unicode-segmentation", + "url", + "usvg 0.46.0", + "uuid", + "waker-fn", + "windows 0.62.2", + "zed-scap", +] + +[[package]] +name = "gpui-pre-apple" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c0bea8ffa563d3a1ada533e148bb55be7fb873c178979e889a3d1204f8e1b91" +dependencies = [ "anyhow", "async-task", "block2 0.6.2", "cbindgen", - "collections", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", - "core-graphics", + "core-graphics 0.24.0", "core-text", "dispatch2", "etagere", "futures", - "gpui", - "gpui_util", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-util", "image", "libc", "log", @@ -1761,20 +2131,61 @@ dependencies = [ "parking_lot", "pathfinder_geometry", "smallvec", - "thiserror", + "thiserror 2.0.21", "zed-font-kit", ] [[package]] -name = "gpui_ios" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +name = "gpui-pre-collections" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06882d54d40681ae3b145af9ef8a4021c2bf9967cc0a9fcf0cc50f003bf3cdf3" +dependencies = [ + "gpui-pre-util", + "indexmap", + "rustc-hash 2.1.3", +] + +[[package]] +name = "gpui-pre-derive-refineable" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3999591b50cfd74f1734baf97fe802cce5f282d60d85b908e3393d534fbd50fb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "gpui-pre-http-client" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00ff0e68341f52c5903182d7e2e2d7dc247d87b0d9947ce3564e453720347a0" +dependencies = [ + "anyhow", + "bytes", + "futures", + "http", + "http-body", + "parking_lot", + "serde", + "serde_json", + "serde_urlencoded", + "url", +] + +[[package]] +name = "gpui-pre-ios" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "326382c3b3f004617ac0f61338594c00f053b08b7875f3486ea0f221e9b20a75" dependencies = [ "anyhow", "block2 0.6.2", "futures", - "gpui", - "gpui_apple", + "gpui-pre", + "gpui-pre-apple", "log", "objc2 0.6.5", "objc2-core-foundation", @@ -1787,19 +2198,20 @@ dependencies = [ ] [[package]] -name = "gpui_linux" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +name = "gpui-pre-linux" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cac5cbd3485c1e3db8eafd5dc69019188f5cb0a39846946af772efa386f624" dependencies = [ "accesskit", "anyhow", "bytemuck", "calloop", - "collections", "futures", - "gpui", - "gpui_util", - "http_client", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-http-client", + "gpui-pre-util", "libc", "log", "notify-rust", @@ -1814,27 +2226,29 @@ dependencies = [ ] [[package]] -name = "gpui_macos" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +name = "gpui-pre-macos" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7410cfdaa1fa55a414346cc492d18ce340e3a8b6d0ac3213b984ed690c82b92" dependencies = [ "accesskit", "accesskit_macos", "anyhow", "block2 0.6.2", - "collections", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", - "core-graphics", + "core-graphics 0.24.0", "ctor", "dispatch2", "foreign-types", "futures", - "gpui", - "gpui_apple", - "gpui_util", + "gpui-pre", + "gpui-pre-apple", + "gpui-pre-collections", + "gpui-pre-scheduler", + "gpui-pre-util", "image", - "itertools", + "itertools 0.14.0", "log", "objc2 0.6.5", "objc2-app-kit 0.3.2", @@ -1846,7 +2260,6 @@ dependencies = [ "objc2-user-notifications", "parking_lot", "raw-window-handle", - "scheduler", "semver", "smallvec", "strum", @@ -1854,35 +2267,115 @@ dependencies = [ ] [[package]] -name = "gpui_macros" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +name = "gpui-pre-macros" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee1c52959baa6834db3183e86affe70242ca90b4fc64ed2ae6dd689a799ce78e" dependencies = [ "heck 0.5.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.119", ] [[package]] -name = "gpui_platform" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +name = "gpui-pre-platform" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175f55bd6a80ea3bb0cf44d9ba454a3312381d66e702b3b5e99f73a7ca1738ee" dependencies = [ "anyhow", "console_error_panic_hook", - "gpui", - "gpui_ios", - "gpui_linux", - "gpui_macos", - "gpui_web", - "gpui_windows", + "gpui-pre", + "gpui-pre-ios", + "gpui-pre-linux", + "gpui-pre-macos", + "gpui-pre-web", + "gpui-pre-windows", ] [[package]] -name = "gpui_shared_string" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +name = "gpui-pre-refineable" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "097375d98fecafd6e00ec13c7711709cc6d827ccad385694ce9e2c2f570c02a5" +dependencies = [ + "gpui-pre-derive-refineable", +] + +[[package]] +name = "gpui-pre-reqwest" +version = "0.12.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05be23908e707966824f8c51a609904b7f30739e8d915e120a53c1b995ba964c" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "once_cell", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-native-certs", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-rustls", + "tokio-socks", + "tokio-util", + "tower", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "windows-registry 0.4.0", +] + +[[package]] +name = "gpui-pre-scheduler" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93c3c9f76341cdf03801699d68876915b42d5858e80c52a141ebfc350358147b" +dependencies = [ + "async-task", + "backtrace", + "chrono", + "flume", + "futures", + "parking_lot", + "rand 0.9.5", + "wasm_thread", + "web-time", +] + +[[package]] +name = "gpui-pre-shared-string" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "781aaf9a9c19e3367b94100c7b1a17283721766bc890909ce8aa34e8c5b1a99a" dependencies = [ "schemars 1.2.3", "serde", @@ -1890,9 +2383,22 @@ dependencies = [ ] [[package]] -name = "gpui_util" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +name = "gpui-pre-sum-tree" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0cb18765472fb2c64b7e6374f9a36f512ce25b1ccb3662fff51788564107eb3" +dependencies = [ + "gpui-pre-ztracing", + "heapless", + "rayon", + "tracing", +] + +[[package]] +name = "gpui-pre-util" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49036ad9887034f8d6652b65761ee1c1fdbd7d66be33a6088245fae54dc643e3" dependencies = [ "anyhow", "log", @@ -1900,22 +2406,23 @@ dependencies = [ ] [[package]] -name = "gpui_web" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +name = "gpui-pre-web" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad51fcf6e251d45b092fd4a98a26325eb9cf6ad4e6228b0c6bc50672db9e0563" dependencies = [ "anyhow", "async-channel", "console_error_panic_hook", "futures", - "gpui", - "gpui_wgpu", - "http_client", + "gpui-pre", + "gpui-pre-http-client", + "gpui-pre-scheduler", + "gpui-pre-wgpu", "js-sys", "log", "parking_lot", "raw-window-handle", - "scheduler", "smallvec", "unicode-properties", "unicode-script", @@ -1929,18 +2436,19 @@ dependencies = [ ] [[package]] -name = "gpui_wgpu" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +name = "gpui-pre-wgpu" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211cf352de1a0b0c9bea3f105df5572ce1d2c899389b924a888e5af36b50f93d" dependencies = [ "anyhow", "bytemuck", - "collections", "cosmic-text", "etagere", - "gpui", - "gpui_util", - "itertools", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-util", + "itertools 0.14.0", "log", "parking_lot", "profiling", @@ -1954,32 +2462,78 @@ dependencies = [ ] [[package]] -name = "gpui_windows" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +name = "gpui-pre-windows" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5141a3db59bab761a16ab19529fe4117a86e91922e8fc00af6dfc4829cb1744d" dependencies = [ "accesskit", "accesskit_windows", "anyhow", - "collections", "dunce", "etagere", "futures", - "gpui", - "gpui_util", + "gpui-pre", + "gpui-pre-collections", + "gpui-pre-scheduler", + "gpui-pre-util", "image", - "itertools", + "itertools 0.14.0", "log", "parking_lot", "rand 0.9.5", "raw-window-handle", - "scheduler", "smallvec", "uuid", "windows 0.62.2", "windows-core 0.62.2", "windows-numerics 0.3.1", - "windows-registry", + "windows-registry 0.6.1", +] + +[[package]] +name = "gpui-pre-ztracing" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ac9274f4fb36ce6bf128f9b48032196a3120cb858503af33ced6c21366cf2f7" +dependencies = [ + "gpui-pre-ztracing-macro", + "tracing", +] + +[[package]] +name = "gpui-pre-ztracing-macro" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b18b06f5026b3d312488eacfdddbe78f82b4bf32bb2d2bc3186e31e552b797" + +[[package]] +name = "granit-parser" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f99e46474f56bd905c56e817ebddcf377a611f94c53ac4649e4d3fa3c0cd0" +dependencies = [ + "arraydeque", + "smallvec", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] @@ -2097,6 +2651,20 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "html5ever" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "http" version = "1.5.0" @@ -2130,23 +2698,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http_client" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" -dependencies = [ - "anyhow", - "bytes", - "futures", - "http", - "http-body", - "parking_lot", - "serde", - "serde_json", - "serde_urlencoded", - "url", -] - [[package]] name = "httparse" version = "1.10.1" @@ -2170,7 +2721,9 @@ checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", + "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -2191,6 +2744,7 @@ dependencies = [ "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -2394,6 +2948,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484be5657551d7868d67587df967fb4fcbe9d266a761cc855aaa148fda0b3cc7" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.10" @@ -2404,7 +2974,7 @@ dependencies = [ "byteorder-lite", "color_quant", "exr", - "gif", + "gif 0.14.2", "image-webp", "moxcms", "num-traits", @@ -2413,8 +2983,8 @@ dependencies = [ "ravif", "rayon", "tiff", - "zune-core", - "zune-jpeg", + "zune-core 0.5.3", + "zune-jpeg 0.5.15", ] [[package]] @@ -2427,6 +2997,12 @@ dependencies = [ "quick-error", ] +[[package]] +name = "imagesize" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edcd27d72f2f071c64249075f42e205ff93c9a4c5f6c6da53e79ed9f9832c285" + [[package]] name = "imagesize" version = "0.14.0" @@ -2451,6 +3027,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "inotify" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" +dependencies = [ + "bitflags 2.13.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + [[package]] name = "inout" version = "0.1.4" @@ -2461,6 +3057,18 @@ dependencies = [ "generic-array", ] +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "interpolate_name" version = "0.2.4" @@ -2487,6 +3095,24 @@ version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -2514,7 +3140,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror", + "thiserror 2.0.21", "walkdir", "windows-link 0.2.1", ] @@ -2598,6 +3224,37 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" +[[package]] +name = "kqueue" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.2", + "libc", +] + +[[package]] +name = "kurbo" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62026ae44756f8a599ba21140f350303d4f08dcdcc71b5ad9c9bb8128c13c62" +dependencies = [ + "arrayvec", + "euclid", + "smallvec", +] + [[package]] name = "kurbo" version = "0.13.1" @@ -2732,6 +3389,19 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" +[[package]] +name = "lsp-types" +version = "0.97.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53353550a17c04ac46c585feb189c2db82154fc84b79c7a66c96c2c644f66071" +dependencies = [ + "bitflags 1.3.2", + "fluent-uri", + "serde", + "serde_json", + "serde_repr", +] + [[package]] name = "lyon" version = "1.0.19" @@ -2784,6 +3454,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + [[package]] name = "mac-notification-sys" version = "0.6.15" @@ -2805,20 +3481,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" [[package]] -name = "macro_rules_attribute" -version = "0.2.3" +name = "malloc_buf" +version = "0.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3ae8f6d608c795738406608304d30a2dfbdc8e58e44f7ba43236da5208ded3c" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" dependencies = [ - "macro_rules_attribute-proc_macro", - "pastey 0.2.3", + "libc", ] [[package]] -name = "macro_rules_attribute-proc_macro" -version = "0.2.3" +name = "markdown" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" +dependencies = [ + "serde", + "unicode-id", +] + +[[package]] +name = "markup5ever" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45" +dependencies = [ + "log", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc04a4c58212d57930a24bf47d3fa87485264a3a054e9c10e042eb373573ad3c" +checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18" +dependencies = [ + "html5ever", + "markup5ever", + "tendril", + "xml5ever", +] [[package]] name = "maybe-rayon" @@ -2864,6 +3569,22 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2891,6 +3612,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -2905,10 +3627,17 @@ dependencies = [ "pxfm", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "naga" -version = "30.0.0" -source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" dependencies = [ "arrayvec", "bit-set", @@ -2926,19 +3655,20 @@ dependencies = [ "once_cell", "rustc-hash 1.1.0", "spirv", - "thiserror", + "thiserror 2.0.21", "unicode-ident", ] [[package]] name = "naga-types" -version = "30.0.0" -source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" dependencies = [ "hashbrown 0.17.1", "indexmap", "rustc-hash 1.1.0", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -2965,6 +3695,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + [[package]] name = "nom" version = "8.0.0" @@ -2980,6 +3716,34 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "normpath" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b11ce00d2594068e8a27c9146fdc5cf9f3ac38eb42c7cd34d05fea618873ac9f" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "notify" +version = "8.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f812702fbc75d35416d3f884e04925be7e9498f4eceea2e24ba34559284674" +dependencies = [ + "bitflags 2.13.2", + "inotify", + "kqueue", + "libc", + "log", + "mio", + "notify-types", + "objc2-core-foundation", + "objc2-core-services", + "walkdir", + "windows-sys 0.61.2", +] + [[package]] name = "notify-rust" version = "4.18.2" @@ -2994,6 +3758,15 @@ dependencies = [ "zbus", ] +[[package]] +name = "notify-types" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a437a9308232ce794b1e7fdf540d38ea37efc3c73492640c13b4c8568b8f108" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "ntapi" version = "0.4.3" @@ -3121,6 +3894,27 @@ dependencies = [ "libc", ] +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", + "objc_exception", +] + +[[package]] +name = "objc-foundation" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1add1b659e36c9607c7aab864a76c7a4c2760cd0cd2e120f3fb8b952c7e22bf9" +dependencies = [ + "block", + "objc", + "objc_id", +] + [[package]] name = "objc-sys" version = "0.3.5" @@ -3156,8 +3950,8 @@ dependencies = [ "block2 0.5.1", "libc", "objc2 0.5.3", - "objc2-core-data", - "objc2-core-image", + "objc2-core-data 0.2.2", + "objc2-core-image 0.2.2", "objc2-foundation 0.2.2", "objc2-quartz-core 0.2.2", ] @@ -3170,12 +3964,31 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", + "libc", "objc2 0.6.5", + "objc2-cloud-kit", + "objc2-core-data 0.3.2", "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-text", + "objc2-core-video", "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "objc2-uniform-type-identifiers", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-data" version = "0.2.2" @@ -3188,6 +4001,17 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + [[package]] name = "objc2-core-foundation" version = "0.3.2" @@ -3226,6 +4050,26 @@ dependencies = [ "objc2-metal 0.2.2", ] +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2 0.6.5", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-core-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -3233,7 +4077,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ "bitflags 2.13.2", + "objc2 0.6.5", "objc2-core-foundation", + "objc2-core-graphics", ] [[package]] @@ -3389,6 +4235,24 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc_exception" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +dependencies = [ + "cc", +] + +[[package]] +name = "objc_id" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92d4ddb4bd7b50d730c215ff871754d0da6b2178849f8a2a2ab69712d0c073b" +dependencies = [ + "objc", +] + [[package]] name = "object" version = "0.40.0" @@ -3407,7 +4271,8 @@ checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "oo7" version = "0.6.0" -source = "git+https://github.com/zed-industries/oo7?rev=731c1215edb6905876c6137ab300428c7b8d7c13#731c1215edb6905876c6137ab300428c7b8d7c13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f2bfed90f1618b4b48dcad9307f25e14ae894e2949642c87c351601d62cebd" dependencies = [ "aes", "ashpd", @@ -3510,12 +4375,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" -[[package]] -name = "pastey" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4" - [[package]] name = "pathfinder_geometry" version = "0.5.1" @@ -3527,29 +4386,67 @@ dependencies = [ ] [[package]] -name = "pathfinder_simd" -version = "0.5.6" +name = "pathfinder_simd" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +dependencies = [ + "rustc_version", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4500030c302e4af1d423f36f3b958d1aecb6c04184356ed5a833bf6b60435777" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ - "rustc_version", + "phf_generator", + "phf_shared", ] [[package]] -name = "pbkdf2" -version = "0.12.2" +name = "phf_generator" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", + "phf_shared", + "rand 0.8.8", ] [[package]] -name = "percent-encoding" -version = "2.3.2" +name = "phf_shared" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] [[package]] name = "pico-args" @@ -3696,6 +4593,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "presser" version = "0.3.1" @@ -3816,7 +4719,7 @@ dependencies = [ "rustc-hash 2.1.3", "rustls", "socket2", - "thiserror", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -3839,7 +4742,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -3993,7 +4896,7 @@ dependencies = [ "built", "cfg-if", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -4007,7 +4910,7 @@ dependencies = [ "rand 0.9.5", "rand_chacha 0.9.0", "simd_helpers", - "thiserror", + "thiserror 2.0.21", "v_frame", "wasm-bindgen", ] @@ -4111,6 +5014,17 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.3" @@ -4118,7 +5032,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ "libredox", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -4141,14 +5055,6 @@ dependencies = [ "syn 3.0.9", ] -[[package]] -name = "refineable" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" -dependencies = [ - "derive_refineable", -] - [[package]] name = "regex" version = "1.13.1" @@ -4231,21 +5137,38 @@ dependencies = [ "web-sys", ] +[[package]] +name = "resvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8928798c0a55e03c9ca6c4c6846f76377427d2c1e1f7e6de3c06ae57942df43" +dependencies = [ + "gif 0.13.3", + "image-webp", + "log", + "pico-args", + "rgb", + "svgtypes 0.15.3", + "tiny-skia", + "usvg 0.45.1", + "zune-jpeg 0.4.21", +] + [[package]] name = "resvg" version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b563218631706d614e23059436526d005b50ab5f2d506b55a17eb65c5eb83419" dependencies = [ - "gif", + "gif 0.14.2", "image-webp", "log", "pico-args", "rgb", - "svgtypes", + "svgtypes 0.16.2", "tiny-skia", - "usvg", - "zune-jpeg", + "usvg 0.46.0", + "zune-jpeg 0.5.15", ] [[package]] @@ -4271,6 +5194,15 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ropey" +version = "2.0.0-beta.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4045a00dc327d084a2bbf126976e14125b54f23bd30511d45b842eba76c52d74" +dependencies = [ + "str_indices", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -4286,6 +5218,91 @@ dependencies = [ "memchr", ] +[[package]] +name = "rust-embed" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19afa5b4b6a611de00bd1bdae6ae6f39084c9399f0679c3f52d8469cf335cc23" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0d8afda6374eac59e066abee06d265247ebbaf3006cf878e2879e8356e34053" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "shellexpand", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a" +dependencies = [ + "globset", + "sha2 0.11.1", + "walkdir", +] + +[[package]] +name = "rust-i18n" +version = "4.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8502e73e6461c738659455c5d2cf95bc132760a6f203c717e66ee72b78906d7f" +dependencies = [ + "globwalk", + "regex", + "rust-i18n-macro", + "rust-i18n-support", + "smallvec", +] + +[[package]] +name = "rust-i18n-macro" +version = "4.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54a28dd9d46108d56b9f0c85b220908844233e561c03087890325104f90f2c6" +dependencies = [ + "glob", + "proc-macro2", + "quote", + "rust-i18n-support", + "serde", + "serde_json", + "syn 2.0.119", +] + +[[package]] +name = "rust-i18n-support" +version = "4.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e806c2a9701cbd6043992cd935c30cb1fdb128fa15e7b506f3b46f97045737c0" +dependencies = [ + "arc-swap", + "base62", + "foldhash", + "globwalk", + "itertools 0.11.0", + "normpath", + "serde", + "serde-saphyr", + "serde_json", + "siphasher", + "toml 0.8.23", + "triomphe", +] + [[package]] name = "rustc-demangle" version = "0.1.28" @@ -4334,6 +5351,7 @@ checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "once_cell", + "ring", "rustls-pki-types", "rustls-webpki", "subtle", @@ -4352,6 +5370,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -4368,7 +5395,7 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni", "log", @@ -4449,22 +5476,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "scheduler" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" -dependencies = [ - "async-task", - "backtrace", - "chrono", - "flume", - "futures", - "parking_lot", - "rand 0.9.5", - "wasm_thread", - "web-time", -] - [[package]] name = "schemars" version = "0.8.22" @@ -4521,6 +5532,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "screencapturekit" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5eeeb57ac94960cfe5ff4c402be6585ae4c8d29a2cf41b276048c2e849d64e" +dependencies = [ + "screencapturekit-sys", +] + +[[package]] +name = "screencapturekit-sys" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22411b57f7d49e7fe08025198813ee6fd65e1ee5eff4ebc7880c12c82bde4c60" +dependencies = [ + "block", + "dispatch", + "objc", + "objc-foundation", + "objc_id", + "once_cell", +] + [[package]] name = "seahash" version = "4.1.0" @@ -4534,7 +5568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.2", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4576,6 +5610,23 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-saphyr" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8050abb251097357e24aff63ba2c52a6309ecb7d23a5474023df960a02694d8" +dependencies = [ + "annotate-snippets", + "base64 0.23.1", + "encoding_rs_io", + "granit-parser", + "nohash-hasher", + "num-traits", + "serde_core", + "smallvec", + "zmij", +] + [[package]] name = "serde_bytes" version = "0.11.19" @@ -4735,6 +5786,15 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "shellexpand" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" +dependencies = [ + "dirs 6.0.0", +] + [[package]] name = "shlex" version = "2.0.1" @@ -4782,6 +5842,16 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +dependencies = [ + "bstr", + "unicode-segmentation", +] + [[package]] name = "simplecss" version = "0.2.2" @@ -4913,6 +5983,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "str_indices" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d08889ec5408683408db66ad89e0e1f93dff55c73a4ccc71c427d5b277ee47e6" + [[package]] name = "strict-num" version = "0.1.1" @@ -4922,6 +5998,31 @@ dependencies = [ "float-cmp", ] +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "strum" version = "0.28.0" @@ -4949,30 +6050,29 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "sum_tree" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" -dependencies = [ - "heapless", - "rayon", - "tracing", - "ztracing", -] - [[package]] name = "svg_fmt" version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" +[[package]] +name = "svgtypes" +version = "0.15.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68c7541fff44b35860c1a7a47a7cadf3e4a304c457b58f9870d9706ece028afc" +dependencies = [ + "kurbo 0.11.3", + "siphasher", +] + [[package]] name = "svgtypes" version = "0.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f12b12995e3638c70618c1facb1ce65302f6a6f706b675f07c58a5d7391120e" dependencies = [ - "kurbo", + "kurbo 0.13.1", "siphasher", ] @@ -5052,6 +6152,27 @@ dependencies = [ "windows 0.57.0", ] +[[package]] +name = "system-configuration" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" +dependencies = [ + "bitflags 2.13.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "t3-desktop-gpui" version = "0.1.0" @@ -5059,8 +6180,10 @@ dependencies = [ "async-channel", "futures-util", "getrandom 0.4.3", - "gpui", - "gpui_platform", + "gpui-component", + "gpui-kit-assets", + "gpui-pre", + "gpui-pre-platform", "hex", "hmac 0.13.0", "libc", @@ -5087,13 +6210,25 @@ dependencies = [ "smallvec", ] +[[package]] +name = "tao-core-video-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "271450eb289cb4d8d0720c6ce70c72c8c858c93dd61fc625881616752e6b98f6" +dependencies = [ + "cfg-if", + "core-foundation-sys", + "libc", + "objc", +] + [[package]] name = "tauri-winrt-notification" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f37a6c354fd28fc9e322ed9bd47e3959576dad28c9d58ea1cf888cce1c7ccb36" dependencies = [ - "thiserror", + "thiserror 2.0.21", "windows 0.62.2", "windows-version", ] @@ -5111,6 +6246,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "termcolor" version = "1.4.1" @@ -5120,13 +6266,33 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.21", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -5151,7 +6317,7 @@ dependencies = [ "half", "quick-error", "weezl", - "zune-jpeg", + "zune-jpeg 0.5.15", ] [[package]] @@ -5253,6 +6419,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-socks" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615" +dependencies = [ + "either", + "futures-util", + "thiserror 1.0.69", + "tokio", +] + [[package]] name = "tokio-tungstenite" version = "0.30.0" @@ -5262,7 +6440,21 @@ dependencies = [ "futures-util", "log", "tokio", - "tungstenite", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", ] [[package]] @@ -5434,6 +6626,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "triomphe" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d15f4f5898bcfc5d8a8e45ed30b2f108d710c1a2e5489dbf80e078f707181bc" +dependencies = [ + "arc-swap", + "serde", + "stable_deref_trait", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -5462,7 +6665,7 @@ dependencies = [ "log", "rand 0.10.3", "sha1", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -5487,7 +6690,7 @@ dependencies = [ "serde_json", "strum", "syn 3.0.9", - "thiserror", + "thiserror 2.0.21", ] [[package]] @@ -5516,7 +6719,7 @@ dependencies = [ "serde", "serde_json", "syn 3.0.9", - "thiserror", + "thiserror 2.0.21", "typespace", "unicode-ident", ] @@ -5549,6 +6752,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -5567,6 +6776,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce61d488bcdc9bc8b5d1772c404828b17fc481c0a582b5581e95fb233aef503e" +[[package]] +name = "unicode-id" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65dd75ab2d9d379dbe86d4059e91d8b5a6e7054865eaba1ddbfc7a817ff96392" + [[package]] name = "unicode-ident" version = "1.0.27" @@ -5627,6 +6842,33 @@ dependencies = [ "serde", ] +[[package]] +name = "usvg" +version = "0.45.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80be9b06fbae3b8b303400ab20778c80bbaf338f563afe567cf3c9eea17b47ef" +dependencies = [ + "base64 0.22.1", + "data-url", + "flate2", + "fontdb", + "imagesize 0.13.0", + "kurbo 0.11.3", + "log", + "pico-args", + "roxmltree 0.20.0", + "rustybuzz", + "simplecss", + "siphasher", + "strict-num", + "svgtypes 0.15.3", + "tiny-skia-path", + "unicode-bidi", + "unicode-script", + "unicode-vo", + "xmlwriter", +] + [[package]] name = "usvg" version = "0.46.0" @@ -5637,8 +6879,8 @@ dependencies = [ "data-url", "flate2", "fontdb", - "imagesize", - "kurbo", + "imagesize 0.14.0", + "kurbo 0.13.1", "log", "pico-args", "roxmltree 0.21.2", @@ -5646,7 +6888,7 @@ dependencies = [ "simplecss", "siphasher", "strict-num", - "svgtypes", + "svgtypes 0.16.2", "tiny-skia-path", "unicode-bidi", "unicode-script", @@ -5654,6 +6896,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.1.0" @@ -5824,10 +7072,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasm_thread" version = "0.3.3" -source = "git+https://github.com/zed-industries/wasm_thread?rev=0cf96c7708dfb97ccf3da50347e25edcf75d6937#0cf96c7708dfb97ccf3da50347e25edcf75d6937" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7516db7f32decdadb1c3b8deb1b7d78b9df7606c5cc2f6241737c2ab3a0258e" dependencies = [ "futures", "js-sys", @@ -5884,8 +7146,9 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "wgpu" -version = "30.0.0" -source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" dependencies = [ "arrayvec", "bitflags 2.13.2", @@ -5897,6 +7160,8 @@ dependencies = [ "js-sys", "log", "naga", + "parking_lot", + "portable-atomic", "profiling", "raw-window-handle", "smallvec", @@ -5906,14 +7171,14 @@ dependencies = [ "web-sys", "wgpu-core", "wgpu-hal", - "wgpu-sync", "wgpu-types", ] [[package]] name = "wgpu-core" -version = "30.0.0" -source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" dependencies = [ "arrayvec", "bit-set", @@ -5927,68 +7192,64 @@ dependencies = [ "log", "naga", "naga-types", - "num-traits", + "once_cell", + "parking_lot", + "portable-atomic", "profiling", "raw-window-handle", "rustc-hash 1.1.0", - "scopeguard", "smallvec", - "thiserror", + "thiserror 2.0.21", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", - "wgpu-core-deps-linux-android-bsd", "wgpu-core-deps-wasm", - "wgpu-core-deps-windows", + "wgpu-core-deps-windows-linux-android", "wgpu-hal", "wgpu-naga-bridge", - "wgpu-sync", "wgpu-types", ] [[package]] name = "wgpu-core-deps-apple" -version = "30.0.0" -source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "30.0.0" -source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" -dependencies = [ - "wgpu-hal", -] - -[[package]] -name = "wgpu-core-deps-linux-android-bsd" -version = "30.0.0" -source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-wasm" -version = "30.0.0" -source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" dependencies = [ "wgpu-hal", ] [[package]] -name = "wgpu-core-deps-windows" -version = "30.0.0" -source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "30.0.0" -source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" dependencies = [ "android_system_properties", "arrayvec", @@ -6017,7 +7278,11 @@ dependencies = [ "objc2-foundation 0.3.2", "objc2-metal 0.3.2", "objc2-quartz-core 0.3.2", + "once_cell", "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", "profiling", "range-alloc", "raw-window-handle", @@ -6025,12 +7290,11 @@ dependencies = [ "renderdoc-sys", "smallvec", "static_assertions", - "thiserror", + "thiserror 2.0.21", "wasm-bindgen", "wayland-sys", "web-sys", "wgpu-naga-bridge", - "wgpu-sync", "wgpu-types", "windows 0.62.2", "windows-core 0.62.2", @@ -6039,36 +7303,24 @@ dependencies = [ [[package]] name = "wgpu-naga-bridge" -version = "30.0.0" -source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" dependencies = [ "naga", "wgpu-types", ] -[[package]] -name = "wgpu-sync" -version = "30.0.0" -source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" -dependencies = [ - "cfg-if", - "lock_api", - "once_cell", - "parking_lot", - "portable-atomic", - "portable-atomic-util", -] - [[package]] name = "wgpu-types" -version = "30.0.0" -source = "git+https://github.com/gfx-rs/wgpu?rev=cddb2f5d80dc0009b2ef6767635140cacf90a007#cddb2f5d80dc0009b2ef6767635140cacf90a007" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" dependencies = [ "bitflags 2.13.2", "bytemuck", "js-sys", "log", - "macro_rules_attribute", "naga-types", "raw-window-handle", "static_assertions", @@ -6122,7 +7374,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "12342cb4d8e3b046f3d80effd474a7a02447231330ef77d71daa6fbc40681143" dependencies = [ "windows-core 0.57.0", - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", ] [[package]] @@ -6158,7 +7420,7 @@ checksum = "3a4df73e95feddb9ec1a7e9c2ca6323b8c97d5eeeff78d28f1eccdf19c882b24" dependencies = [ "parking_lot", "rayon", - "thiserror", + "thiserror 2.0.21", "windows 0.61.3", "windows-future 0.2.1", ] @@ -6190,7 +7452,20 @@ dependencies = [ "windows-implement 0.57.0", "windows-interface 0.57.0", "windows-result 0.1.2", - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", ] [[package]] @@ -6252,6 +7527,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -6274,6 +7560,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -6317,6 +7614,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-registry" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4286ad90ddb45071efd1a66dfa43eb02dd0dfbae1545ad6cc3c51cf34d7e8ba3" +dependencies = [ + "windows-result 0.3.4", + "windows-strings 0.3.1", + "windows-targets 0.53.5", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -6334,7 +7642,16 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", ] [[package]] @@ -6355,6 +7672,25 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87fa48cc5d406560701792be122a10132491cff9d0aeb23583cc2dcafc847319" +dependencies = [ + "windows-link 0.1.3", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -6373,13 +7709,22 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets", + "windows-targets 0.52.6", ] [[package]] @@ -6391,20 +7736,52 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[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", + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -6434,54 +7811,144 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[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_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[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_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.7.15" @@ -6559,6 +8026,17 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" +[[package]] +name = "xml5ever" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69" +dependencies = [ + "log", + "mac", + "markup5ever", +] + [[package]] name = "xmlwriter" version = "0.1.0" @@ -6684,14 +8162,15 @@ dependencies = [ [[package]] name = "zed-font-kit" version = "0.14.1-zed" -source = "git+https://github.com/zed-industries/font-kit?rev=94b0f28166665e8fd2f53ff6d268a14955c82269#94b0f28166665e8fd2f53ff6d268a14955c82269" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3898e450f36f852edda72e3f985c34426042c4951790b23b107f93394f9bff5" dependencies = [ "bitflags 2.13.2", "byteorder", - "core-foundation", - "core-graphics", + "core-foundation 0.10.1", + "core-graphics 0.24.0", "core-text", - "dirs", + "dirs 5.0.1", "dwrote", "float-ord", "freetype-sys", @@ -6708,12 +8187,19 @@ dependencies = [ [[package]] name = "zed-scap" version = "0.0.8-zed" -source = "git+https://github.com/zed-industries/scap?rev=b5c86ec58dd4b6b7732bf6ac8be03d6b1f190a82#b5c86ec58dd4b6b7732bf6ac8be03d6b1f190a82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b338d705ae33a43ca00287c11129303a7a0aa57b101b72a1c08c863f698ac8" dependencies = [ "anyhow", + "cocoa", + "core-graphics-helmer-fork", "log", + "objc", "rand 0.8.8", + "screencapturekit", + "screencapturekit-sys", "sysinfo", + "tao-core-video-sys", "windows 0.61.3", "windows-capture", "x11", @@ -6835,18 +8321,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] -name = "ztracing" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" -dependencies = [ - "tracing", - "ztracing_macro", -] - -[[package]] -name = "ztracing_macro" -version = "0.1.0" -source = "git+https://github.com/zed-industries/zed?rev=c7bea343838f42b4c14151393e41f0bbe033c95e#c7bea343838f42b4c14151393e41f0bbe033c95e" +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" [[package]] name = "zune-core" @@ -6863,13 +8341,22 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core 0.4.12", +] + [[package]] name = "zune-jpeg" version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" dependencies = [ - "zune-core", + "zune-core 0.5.3", ] [[package]] diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index a3d580d795e1..2d27e6b534c7 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -9,10 +9,10 @@ publish = false async-channel = "2.5.0" futures-util = "0.3.34" getrandom = "0.4.3" -gpui = { git = "https://github.com/zed-industries/zed", rev = "c7bea343838f42b4c14151393e41f0bbe033c95e" } -gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "c7bea343838f42b4c14151393e41f0bbe033c95e", features = [ - "font-kit", -] } +gpui = { package = "gpui-pre", version = "=0.3.11" } +gpui-component = "=0.9.0" +gpui-kit-assets = "=0.9.0" +gpui_platform = { package = "gpui-pre-platform", version = "=0.3.11", features = ["font-kit"] } hex = "0.4.3" hmac = "0.13.0" libc = "0.2.191" diff --git a/apps/desktop-gpui/README.md b/apps/desktop-gpui/README.md index 72d22c08abd3..7f7fa8b2ea0a 100644 --- a/apps/desktop-gpui/README.md +++ b/apps/desktop-gpui/README.md @@ -2,7 +2,7 @@ Native desktop client written in Rust with [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui). It is meant to replace `apps/desktop` (Electron) over time and never embeds Node or Chromium. It starts the T3 server as a child process, authenticates without pairing, and opens the RPC WebSocket. -Status: connection shell. The window shows the connection state ("Starting server...", "Connecting...", "Connected to · N projects · M threads", or the error). The counts come from a live `orchestration.subscribeShell` subscription. There are no screens yet. +Status: connection shell. The window shows the connection state ("Starting server...", "Connecting...", "Connected to · N projects · M threads", or the error). The counts come from a live `orchestration.subscribeShell` subscription. Once connected, it draws a static skeleton of the main screen with [gpui-component](https://github.com/longbridge/gpui-component): a thread list (newest first) and an empty composer. Nothing is interactive yet. ## Run @@ -63,7 +63,7 @@ Known gaps: typify cannot flatten the `projectIcon` union (an `anyOf` with a `{k ## Notes -- `gpui` and `gpui_platform` are pinned to one `zed-industries/zed` commit in `Cargo.toml`. Bump both together. +- `gpui` and `gpui_platform` are the crates.io packages `gpui-pre` and `gpui-pre-platform`, pinned to the exact version `gpui-component` requires. Bump them together with `gpui-component` and `gpui-kit-assets`. - macOS needs the Xcode Metal toolchain (`xcodebuild -downloadComponent MetalToolchain`) and the `font-kit` feature, which is enabled. - Server spawning uses a unix pipe for fd 3, so it is macOS and Linux only for now. - CI does not build this crate yet. GPUI needs a macOS runner or Linux system libraries (wayland, x11, xkbcommon, vulkan). diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index c6c5e658f6e2..a7a2f59b0f71 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -3,7 +3,7 @@ use crate::auth::{self, AuthError, Credential}; use crate::rpc::{Event, PING_FRAME, Session, SubscribeShell}; use crate::shell::ShellState; -use crate::status::{Counts, Status, StatusSender, publish}; +use crate::status::{Status, StatusSender, publish}; use crate::wire::{ShellItem, SubscribeShellPayload}; use futures_util::{SinkExt, StreamExt}; use std::time::Duration; @@ -79,7 +79,7 @@ pub async fn run(http_base: &str, credential: &Credential, label: &str, status: status, Status::Connected { label: label.to_owned(), - counts: None, + shell: None, }, ); backoff.reset(); @@ -196,10 +196,7 @@ fn handle( status, Status::Connected { label: label.to_owned(), - counts: Some(Counts { - projects: shell.project_count(), - threads: shell.thread_count(), - }), + shell: Some(shell.view()), }, ); } diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 3ee9c7d1f861..76eac738b6f4 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -5,35 +5,18 @@ mod rpc; mod server; mod shell; mod status; +mod ui; mod wire; use config::{Attach, Config}; -use gpui::{ - App, Bounds, Context, SharedString, TitlebarOptions, Window, WindowBounds, WindowOptions, div, - prelude::*, px, rgb, size, -}; +use gpui::{App, AppContext as _, Bounds, WindowBounds, WindowOptions, px, size}; +use gpui_component::{Root, Theme, ThemeMode, TitleBar}; +use gpui_kit_assets::Assets; use gpui_platform::application; use server::Supervisor; use status::Status; use std::rc::Rc; - -struct StatusView { - status: SharedString, -} - -impl Render for StatusView { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - div() - .flex() - .size_full() - .justify_center() - .items_center() - .bg(rgb(0x1a1a1a)) - .text_color(rgb(0xe6e6e6)) - .text_xl() - .child(self.status.clone()) - } -} +use ui::MainView; fn main() { let runtime = tokio::runtime::Builder::new_multi_thread() @@ -72,7 +55,9 @@ fn main() { Status::NotConnected }; - application().run(move |cx: &mut App| { + application().with_assets(Assets).run(move |cx: &mut App| { + gpui_component::init(cx); + Theme::change(ThemeMode::Dark, None, cx); if let Some(supervisor) = supervisor { // The grace period outlasts gpui's quit timeout, so block here instead of awaiting. cx.on_app_quit(move |_| { @@ -85,30 +70,23 @@ fn main() { cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), - titlebar: Some(TitlebarOptions { - title: Some("T3 Code".into()), - ..Default::default() - }), - ..Default::default() + ..TitleBar::window_options() }, - |_, cx| { - cx.new(|cx| { + |window, cx| { + let view = cx.new(|cx| { cx.spawn(async move |this, cx| { while let Ok(status) = status_rx.recv().await { - let updated = this.update(cx, |view: &mut StatusView, cx| { - view.status = status.to_string().into(); - cx.notify(); - }); + let updated = this + .update(cx, |view: &mut MainView, cx| view.set_status(status, cx)); if updated.is_err() { break; } } }) .detach(); - StatusView { - status: initial.to_string().into(), - } - }) + MainView::new(initial, window, cx) + }); + cx.new(|cx| Root::new(view, window, cx)) }, ) .expect("failed to open the main window"); diff --git a/apps/desktop-gpui/src/shell.rs b/apps/desktop-gpui/src/shell.rs index f68bd3c1ec60..324c64297404 100644 --- a/apps/desktop-gpui/src/shell.rs +++ b/apps/desktop-gpui/src/shell.rs @@ -1,6 +1,7 @@ //! The shell read model built from `orchestration.subscribeShell`, mirroring //! `packages/client-runtime/src/state/shellReducer.ts` for what this client shows. +use crate::status::{ShellView, ThreadRow}; use crate::wire::generated::{ OrchestrationSubscribeShellSuccess as Item, OrchestrationSubscribeShellSuccessVariant4Location as Location, @@ -32,6 +33,46 @@ impl ShellState { self.threads.len() } + /// Threads newest first (ties by id); the first project is the oldest by creation. + pub fn view(&self) -> ShellView { + let mut rows: Vec = self + .threads + .values() + .map(|thread| ThreadRow { + id: thread.id.clone(), + project: self + .projects + .get(&thread.project_id) + .map(|project| project.title.clone()) + .unwrap_or_default(), + title: thread.title.clone(), + branch: thread.branch.clone(), + status: thread.status, + updated_at: thread.updated_at.clone(), + }) + .collect(); + rows.sort_by(|a, b| { + b.updated_at + .cmp(&a.updated_at) + .then_with(|| a.id.cmp(&b.id)) + }); + let first_project = self + .projects + .values() + .min_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| a.id.cmp(&b.id)) + }) + .map(|project| project.title.clone()); + ShellView { + projects: self.project_count(), + threads: self.thread_count(), + first_project, + rows, + } + } + /// Applies one stream item. Returns whether the counts or sync marker may have changed. pub fn apply(&mut self, item: Item) -> bool { match item { @@ -176,6 +217,21 @@ mod tests { assert_eq!(state.project_count(), 0); } + #[test] + fn view_lists_newest_threads_first_with_project_names() { + let mut state = seeded(); + state.apply(with(THREAD_UPDATED, |value| { + value["thread"]["id"] = "thread-new".into(); + value["thread"]["updatedAt"] = "2026-07-01T00:00:00.000Z".into(); + })); + let view = state.view(); + assert_eq!((view.projects, view.threads), (1, 2)); + assert_eq!(view.first_project.as_deref(), Some("Project")); + let ids: Vec<_> = view.rows.iter().map(|row| row.id.as_str()).collect(); + assert_eq!(ids, ["thread-new", "thread-v2"]); + assert!(view.rows.iter().all(|row| row.project == "Project")); + } + #[test] fn new_project_id_adds_a_row() { let mut state = seeded(); diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index fb5526b2b966..d1e35de40e9f 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -1,3 +1,4 @@ +use crate::wire::generated::ThreadsEntryStatus; use std::fmt; /// What the window shows about the managed server and its socket. @@ -6,18 +7,31 @@ pub enum Status { NotConnected, Starting, Connecting, - /// Socket is up. Counts stay `None` until the first shell snapshot lands. + /// Socket is up. `shell` stays `None` until the first shell snapshot lands. Connected { label: String, - counts: Option, + shell: Option, }, Failed(String), } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct Counts { +/// What the window needs from the shell state, newest thread first. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ShellView { pub projects: usize, pub threads: usize, + pub first_project: Option, + pub rows: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ThreadRow { + pub id: String, + pub project: String, + pub title: String, + pub branch: Option, + pub status: ThreadsEntryStatus, + pub updated_at: String, } fn plural(count: usize, noun: &str) -> String { @@ -34,13 +48,12 @@ impl fmt::Display for Status { Self::NotConnected => f.write_str("Not connected"), Self::Starting => f.write_str("Starting server..."), Self::Connecting => f.write_str("Connecting..."), + Self::Connected { label, shell: None } => write!(f, "Connected to {label}"), Self::Connected { label, - counts: None, - } => write!(f, "Connected to {label}"), - Self::Connected { - label, - counts: Some(Counts { projects, threads }), + shell: Some(ShellView { + projects, threads, .. + }), } => write!( f, "Connected to {label} \u{b7} {projects} {} \u{b7} {threads} {}", @@ -68,14 +81,15 @@ mod tests { let label = "Local".to_owned(); let waiting = Status::Connected { label: label.clone(), - counts: None, + shell: None, }; assert_eq!(waiting.to_string(), "Connected to Local"); let known = Status::Connected { label: label.clone(), - counts: Some(Counts { + shell: Some(ShellView { projects: 2, threads: 14, + ..Default::default() }), }; assert_eq!( @@ -84,9 +98,10 @@ mod tests { ); let single = Status::Connected { label, - counts: Some(Counts { + shell: Some(ShellView { projects: 1, threads: 1, + ..Default::default() }), }; assert_eq!( diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs new file mode 100644 index 000000000000..d41c2be95800 --- /dev/null +++ b/apps/desktop-gpui/src/ui.rs @@ -0,0 +1,235 @@ +use crate::status::{ShellView, Status, ThreadRow}; +use crate::wire::generated::ThreadsEntryStatus as ThreadStatus; +use gpui::{ + AnyElement, Context, Entity, Hsla, IntoElement, ParentElement, Render, Styled, + UniformListScrollHandle, Window, div, prelude::*, px, uniform_list, +}; +use gpui_component::{ + ActiveTheme, Icon, IconName, Sizable, TitleBar, + breadcrumb::{Breadcrumb, BreadcrumbItem}, + button::{Button, ButtonVariants}, + h_flex, + input::{Input, InputState, Textarea, TextareaState}, + tag::Tag, + v_flex, +}; + +const SIDEBAR_WIDTH: f32 = 300.; +const ROW_HEIGHT: f32 = 60.; + +pub struct MainView { + status: Status, + search: Entity, + composer: Entity, + list: UniformListScrollHandle, +} + +impl MainView { + pub fn new(status: Status, window: &mut Window, cx: &mut Context) -> Self { + Self { + status, + search: cx.new(|cx| InputState::new(window, cx).placeholder("Search threads")), + composer: cx.new(|cx| { + TextareaState::new(window, cx) + .placeholder("Ask anything. @ files, $ skills, / commands") + .auto_grow(3, 10) + }), + list: UniformListScrollHandle::new(), + } + } + + pub fn set_status(&mut self, status: Status, cx: &mut Context) { + self.status = status; + cx.notify(); + } + + fn shell(&self) -> Option<&ShellView> { + match &self.status { + Status::Connected { shell, .. } => shell.as_ref(), + _ => None, + } + } + + fn environment(&self) -> String { + match &self.status { + Status::Connected { label, .. } => label.clone(), + other => other.to_string(), + } + } + + fn sidebar(&self, cx: &mut Context) -> impl IntoElement { + let count = self.shell().map_or(0, |shell| shell.rows.len()); + v_flex() + .w(px(SIDEBAR_WIDTH)) + .flex_shrink_0() + .h_full() + .gap_2() + .p_2() + .bg(cx.theme().sidebar) + .border_r_1() + .border_color(cx.theme().sidebar_border) + .child(Input::new(&self.search).prefix(Icon::new(IconName::Search))) + .child( + uniform_list( + "threads", + count, + cx.processor(|this, range: std::ops::Range, _, cx| { + let Some(shell) = this.shell() else { + return Vec::new(); + }; + let (muted, hover) = (cx.theme().muted_foreground, cx.theme().list_hover); + shell.rows[range] + .iter() + .map(|row| thread_row(row, muted, hover)) + .collect() + }), + ) + .track_scroll(&self.list) + .flex_1() + .min_h_0(), + ) + } + + fn main_area(&self, cx: &mut Context) -> AnyElement { + let Some(shell) = self.shell() else { + return centered(cx) + .text_xl() + .child(self.status.to_string()) + .into_any_element(); + }; + let project = shell + .first_project + .clone() + .unwrap_or_else(|| "your project".to_owned()); + v_flex() + .flex_1() + .min_w_0() + .child( + h_flex() + .h(px(40.)) + .px_4() + .items_center() + .border_b_1() + .border_color(cx.theme().border) + .child( + Breadcrumb::new() + .child(BreadcrumbItem::new(project.clone())) + .child(BreadcrumbItem::new("New thread")), + ), + ) + .child( + centered(cx) + .gap_6() + .child( + div() + .text_2xl() + .child(format!("What should we build in {project}?")), + ) + .child(self.composer_box(cx)), + ) + .into_any_element() + } + + fn composer_box(&self, cx: &mut Context) -> impl IntoElement { + v_flex() + .w(px(720.)) + .max_w_full() + .p_3() + .gap_2() + .rounded_xl() + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().secondary) + .child( + Textarea::new(&self.composer) + .appearance(false) + .bordered(false), + ) + .child( + h_flex().justify_end().child( + Button::new("send") + .primary() + .rounded_full() + .icon(IconName::ArrowUp) + .on_click(|_, _, _| {}), + ), + ) + } +} + +fn centered(cx: &Context) -> gpui::Div { + v_flex() + .flex_1() + .size_full() + .items_center() + .justify_center() + .text_color(cx.theme().foreground) +} + +fn status_tag(status: ThreadStatus) -> Option { + let (tag, label) = match status { + ThreadStatus::Running | ThreadStatus::Starting => (Tag::info(), "Running"), + ThreadStatus::Waiting => (Tag::warning(), "Waiting"), + ThreadStatus::Queued | ThreadStatus::Preparing => (Tag::secondary(), "Queued"), + ThreadStatus::Failed => (Tag::danger(), "Failed"), + _ => return None, + }; + Some(tag.small().child(label)) +} + +fn thread_row(row: &ThreadRow, muted: Hsla, hover: Hsla) -> impl IntoElement + use<> { + v_flex() + .id(row.id.clone()) + .h(px(ROW_HEIGHT)) + .px_2() + .gap_0p5() + .justify_center() + .rounded_md() + .hover(|style| style.bg(hover)) + .child( + div() + .text_xs() + .text_color(muted) + .truncate() + .child(row.project.clone()), + ) + .child( + h_flex() + .gap_2() + .justify_between() + .child(div().text_sm().truncate().child(row.title.clone())) + .children(status_tag(row.status)), + ) + .children(row.branch.as_ref().map(|branch| { + div() + .text_xs() + .text_color(muted) + .truncate() + .child(branch.clone()) + })) +} + +impl Render for MainView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .size_full() + .bg(cx.theme().background) + .text_color(cx.theme().foreground) + .child( + TitleBar::new().child("T3 Code").child( + div() + .ml_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(self.environment()), + ), + ) + .child( + h_flex() + .flex_1() + .min_h_0() + .child(self.sidebar(cx)) + .child(self.main_area(cx)), + ) + } +} From 768f8dd9ed1f860c3e7b03375452162f36d1504b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 5 Nov 2026 00:34:57 -0500 Subject: [PATCH 05/82] fix(desktop-gpui): thread rows no longer clip or overflow the sidebar Rows were a fixed 60px holding three lines, so the project name was cut off, and status tags pushed past the sidebar edge on long titles. The header also floated to the middle of the main area. Signed-off-by: Yordis Prieto --- apps/desktop-gpui/src/ui.rs | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index d41c2be95800..f1af4fad848c 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -15,7 +15,7 @@ use gpui_component::{ }; const SIDEBAR_WIDTH: f32 = 300.; -const ROW_HEIGHT: f32 = 60.; +const ROW_HEIGHT: f32 = 68.; pub struct MainView { status: Status, @@ -104,8 +104,10 @@ impl MainView { v_flex() .flex_1() .min_w_0() + .h_full() .child( h_flex() + .flex_shrink_0() .h(px(40.)) .px_4() .items_center() @@ -160,7 +162,9 @@ impl MainView { fn centered(cx: &Context) -> gpui::Div { v_flex() .flex_1() - .size_full() + .min_h_0() + .w_full() + .px_6() .items_center() .justify_center() .text_color(cx.theme().foreground) @@ -181,8 +185,10 @@ fn thread_row(row: &ThreadRow, muted: Hsla, hover: Hsla) -> impl IntoElement + u v_flex() .id(row.id.clone()) .h(px(ROW_HEIGHT)) + .w_full() + .overflow_hidden() .px_2() - .gap_0p5() + .py_1() .justify_center() .rounded_md() .hover(|style| style.bg(hover)) @@ -196,9 +202,15 @@ fn thread_row(row: &ThreadRow, muted: Hsla, hover: Hsla) -> impl IntoElement + u .child( h_flex() .gap_2() - .justify_between() - .child(div().text_sm().truncate().child(row.title.clone())) - .children(status_tag(row.status)), + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .truncate() + .child(row.title.clone()), + ) + .children(status_tag(row.status).map(|tag| div().flex_shrink_0().child(tag))), ) .children(row.branch.as_ref().map(|branch| { div() From 80ea723065f6ffe757f0755ddc837265009a4e1a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 5 Nov 2026 00:57:50 -0500 Subject: [PATCH 06/82] feat(desktop-gpui): pick the project from the empty-state heading The heading named a fixed project with no way to change it. Match the web empty state: the project name opens a picker, and the start-without-a-project link shows its shortcut. Signed-off-by: Yordis Prieto --- apps/desktop-gpui/src/shell.rs | 15 ++-- apps/desktop-gpui/src/status.rs | 3 +- apps/desktop-gpui/src/ui.rs | 151 +++++++++++++++++++++++++++++--- 3 files changed, 149 insertions(+), 20 deletions(-) diff --git a/apps/desktop-gpui/src/shell.rs b/apps/desktop-gpui/src/shell.rs index 324c64297404..650ef0b79dbf 100644 --- a/apps/desktop-gpui/src/shell.rs +++ b/apps/desktop-gpui/src/shell.rs @@ -56,19 +56,16 @@ impl ShellState { .cmp(&a.updated_at) .then_with(|| a.id.cmp(&b.id)) }); - let first_project = self + let mut project_titles: Vec = self .projects .values() - .min_by(|a, b| { - a.created_at - .cmp(&b.created_at) - .then_with(|| a.id.cmp(&b.id)) - }) - .map(|project| project.title.clone()); + .map(|project| project.title.clone()) + .collect(); + project_titles.sort_by_key(|title| title.to_lowercase()); ShellView { projects: self.project_count(), threads: self.thread_count(), - first_project, + project_titles, rows, } } @@ -226,7 +223,7 @@ mod tests { })); let view = state.view(); assert_eq!((view.projects, view.threads), (1, 2)); - assert_eq!(view.first_project.as_deref(), Some("Project")); + assert_eq!(view.project_titles, ["Project"]); let ids: Vec<_> = view.rows.iter().map(|row| row.id.as_str()).collect(); assert_eq!(ids, ["thread-new", "thread-v2"]); assert!(view.rows.iter().all(|row| row.project == "Project")); diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index d1e35de40e9f..4e5eb66052e5 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -20,7 +20,8 @@ pub enum Status { pub struct ShellView { pub projects: usize, pub threads: usize, - pub first_project: Option, + /// Project titles in picker order. + pub project_titles: Vec, pub rows: Vec, } diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index f1af4fad848c..a6fb60a6a655 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -1,8 +1,8 @@ use crate::status::{ShellView, Status, ThreadRow}; use crate::wire::generated::ThreadsEntryStatus as ThreadStatus; use gpui::{ - AnyElement, Context, Entity, Hsla, IntoElement, ParentElement, Render, Styled, - UniformListScrollHandle, Window, div, prelude::*, px, uniform_list, + Anchor, AnyElement, Context, Entity, Hsla, IntoElement, Keystroke, ParentElement, Render, + Styled, UniformListScrollHandle, Window, div, prelude::*, px, uniform_list, }; use gpui_component::{ ActiveTheme, Icon, IconName, Sizable, TitleBar, @@ -10,7 +10,10 @@ use gpui_component::{ button::{Button, ButtonVariants}, h_flex, input::{Input, InputState, Textarea, TextareaState}, + kbd::Kbd, + popover::Popover, tag::Tag, + tooltip::Tooltip, v_flex, }; @@ -22,6 +25,8 @@ pub struct MainView { search: Entity, composer: Entity, list: UniformListScrollHandle, + selected_project: Option, + picker_open: bool, } impl MainView { @@ -35,6 +40,8 @@ impl MainView { .auto_grow(3, 10) }), list: UniformListScrollHandle::new(), + selected_project: None, + picker_open: false, } } @@ -97,9 +104,7 @@ impl MainView { .child(self.status.to_string()) .into_any_element(); }; - let project = shell - .first_project - .clone() + let project = active_project(self.selected_project.as_deref(), shell) .unwrap_or_else(|| "your project".to_owned()); v_flex() .flex_1() @@ -122,16 +127,88 @@ impl MainView { .child( centered(cx) .gap_6() - .child( - div() - .text_2xl() - .child(format!("What should we build in {project}?")), - ) + .child(self.hero(&project, shell.project_titles.clone(), cx)) .child(self.composer_box(cx)), ) .into_any_element() } + fn hero(&self, project: &str, titles: Vec, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let picker = Popover::new("project-picker") + .anchor(Anchor::TopCenter) + .open(self.picker_open) + .on_open_change({ + let view = view.clone(); + move |open, _, cx| { + view.update(cx, |this, cx| { + this.picker_open = *open; + cx.notify(); + }); + } + }) + .trigger( + Button::new("project") + .ghost() + .tooltip(project.to_owned()) + .child(div().text_2xl().child(project.to_owned())), + ) + .content(move |_, _, cx| { + let hover = cx.theme().list_hover; + v_flex() + .id("project-list") + .w(px(240.)) + .max_h(px(320.)) + .overflow_y_scroll() + .children(titles.iter().enumerate().map(|(index, title)| { + let (view, title) = (view.clone(), title.clone()); + div() + .id(("project", index)) + .px_2() + .py_1() + .rounded_md() + .text_sm() + .truncate() + .cursor_pointer() + .hover(move |style| style.bg(hover)) + .child(title.clone()) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| { + this.selected_project = Some(title.clone()); + this.picker_open = false; + cx.notify(); + }); + }) + })) + }); + v_flex() + .items_center() + .gap_1() + .child( + h_flex() + .items_center() + .text_2xl() + .child("What should we build in") + .child(picker) + .child("?"), + ) + .child( + div() + .id("start-without-project") + .text_sm() + .text_color(cx.theme().muted_foreground) + .cursor_pointer() + .hover(|style| style.underline()) + .tooltip(|window, cx| { + Tooltip::element(|_, _| { + Kbd::new(Keystroke::parse("alt-cmd-n").expect("valid keystroke")) + }) + .build(window, cx) + }) + .child("or start without a project"), + ) + } + fn composer_box(&self, cx: &mut Context) -> impl IntoElement { v_flex() .w(px(720.)) @@ -159,6 +236,22 @@ impl MainView { } } +/// The picked project while it still exists, else the newest thread's project. +fn active_project(selected: Option<&str>, shell: &ShellView) -> Option { + selected + .filter(|title| shell.project_titles.iter().any(|known| known == title)) + .map(str::to_owned) + .or_else(|| { + shell + .rows + .iter() + .map(|row| &row.project) + .find(|project| !project.is_empty()) + .cloned() + }) + .or_else(|| shell.project_titles.first().cloned()) +} + fn centered(cx: &Context) -> gpui::Div { v_flex() .flex_1() @@ -245,3 +338,41 @@ impl Render for MainView { ) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn row(project: &str) -> ThreadRow { + ThreadRow { + id: project.to_owned(), + project: project.to_owned(), + title: String::new(), + branch: None, + status: ThreadStatus::Idle, + updated_at: String::new(), + } + } + + #[test] + fn active_project_prefers_a_live_pick_then_the_newest_thread() { + let shell = ShellView { + project_titles: vec!["alpha".into(), "beta".into()], + rows: vec![row(""), row("beta")], + ..ShellView::default() + }; + assert_eq!( + active_project(Some("alpha"), &shell).as_deref(), + Some("alpha") + ); + assert_eq!( + active_project(Some("gone"), &shell).as_deref(), + Some("beta") + ); + let empty = ShellView { + project_titles: vec!["alpha".into()], + ..ShellView::default() + }; + assert_eq!(active_project(None, &empty).as_deref(), Some("alpha")); + } +} From 15d1a53ca4313f97decff596d52e040b48a99845 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 5 Nov 2026 01:10:44 -0500 Subject: [PATCH 07/82] feat(desktop-gpui): project picker matches the web The hero picker lists projects the way the web does: pinned first, then by recent activity, with the active project leading, colored initials, and a search filter. Signed-off-by: Yordis Prieto --- apps/desktop-gpui/src/main.rs | 1 + apps/desktop-gpui/src/projects.rs | 359 ++++++++++++++++++++++++++++++ apps/desktop-gpui/src/shell.rs | 17 +- apps/desktop-gpui/src/status.rs | 6 +- apps/desktop-gpui/src/ui.rs | 216 +++++++++++++----- 5 files changed, 531 insertions(+), 68 deletions(-) create mode 100644 apps/desktop-gpui/src/projects.rs diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 76eac738b6f4..2c146d5e077a 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -1,6 +1,7 @@ mod auth; mod config; mod connection; +mod projects; mod rpc; mod server; mod shell; diff --git a/apps/desktop-gpui/src/projects.rs b/apps/desktop-gpui/src/projects.rs new file mode 100644 index 000000000000..7f0186db0913 --- /dev/null +++ b/apps/desktop-gpui/src/projects.rs @@ -0,0 +1,359 @@ +//! The project picker's rows, mirroring the web picker: `sortLogicalProjectsForSidebar` +//! (Sidebar.logic.ts), `pickableProjectGroups` (projectGrouping.ts), `deriveProjectIdentity` +//! (projectIdentity.ts) and `buildSidebarProjectPickerEntries` (sidebarProjectGrouping.ts). + +use crate::wire::{Project, Thread}; +use std::collections::HashMap; + +/// `[name, light, dark]` as sRGB hex: Tailwind's 600 and 400 shades, in `PROJECT_ICON_COLORS` order. +const COLORS: [(&str, u32, u32); 18] = [ + ("gray", 0x4b5563, 0x9ca3af), + ("red", 0xdc2626, 0xf87171), + ("orange", 0xea580c, 0xfb923c), + ("amber", 0xd97706, 0xfbbf24), + ("yellow", 0xca8a04, 0xfacc15), + ("lime", 0x65a30d, 0xa3e635), + ("green", 0x16a34a, 0x4ade80), + ("emerald", 0x059669, 0x34d399), + ("teal", 0x0d9488, 0x2dd4bf), + ("cyan", 0x0891b2, 0x22d3ee), + ("sky", 0x0284c7, 0x38bdf8), + ("blue", 0x2563eb, 0x60a5fa), + ("indigo", 0x4f46e5, 0x818cf8), + ("violet", 0x7c3aed, 0xa78bfa), + ("purple", 0x9333ea, 0xc084fc), + ("fuchsia", 0xc026d3, 0xe879f9), + ("pink", 0xdb2777, 0xf472b6), + ("rose", 0xe11d48, 0xfb7185), +]; + +/// The server's title for the managed no-project workspace (ManagedProjectFolders.ts). +const SCRATCH_TITLE: &str = "No project"; +const SCRATCH_FOLDER: &str = "scratch"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ProjectEntry { + pub id: String, + pub title: String, + pub initials: String, + /// Index into the shared palette. + pub color: usize, + pub pinned: bool, + pub scratch: bool, +} + +impl ProjectEntry { + /// The badge text color for the theme; the badge background is this at 14% alpha. + pub fn color_hex(&self, dark: bool) -> u32 { + let (_, light, night) = COLORS[self.color]; + if dark { night } else { light } + } +} + +fn words(text: &str) -> Vec<&str> { + text.split(|c: char| !c.is_alphanumeric()) + .filter(|word| !word.is_empty()) + .collect() +} + +fn initials(name: &str) -> String { + let words = words(name); + let Some(first_word) = words.first() else { + return "PR".to_owned(); + }; + let glyphs: Vec = first_word.chars().collect(); + let first = glyphs[0]; + let second = glyphs[1..] + .iter() + .copied() + .find(|glyph| glyph.is_numeric()) + .or_else(|| { + if words.len() > 1 { + words.last().and_then(|word| word.chars().next()) + } else { + glyphs.last().copied() + } + }) + .unwrap_or(first); + format!("{first}{second}") + .to_uppercase() + .chars() + .take(2) + .collect() +} + +fn color_index(name: &str) -> usize { + let lowered = name.trim().to_lowercase(); + let seed = if lowered.is_empty() { + "project" + } else { + &lowered + }; + seed.chars().fold(0, |index, glyph| { + (index * 31 + glyph as usize) % COLORS.len() + }) +} + +/// A monogram icon override wins, as in `ProjectFavicon`; other icon kinds fall back to the derived one. +fn monogram_override(project: &Project) -> Option<(String, usize)> { + let icon = project.project_icon.as_ref()?; + if icon.get("kind")?.as_str()? != "monogram" { + return None; + } + let text = icon.get("text")?.as_str()?.to_owned(); + let color = icon.get("color")?.as_str()?; + let index = COLORS.iter().position(|(name, ..)| *name == color)?; + Some((text, index)) +} + +/// The server creates the scratch project at `/scratch` titled "No project", and the +/// shell does not carry the server config's `scratchWorkspaceRoot`, so match on both. +fn is_scratch(project: &Project) -> bool { + let root = project.workspace_root.trim_end_matches(['/', '\\']); + project.title == SCRATCH_TITLE + && root + .rsplit(['/', '\\']) + .next() + .is_some_and(|folder| folder == SCRATCH_FOLDER) +} + +/// ISO timestamps from the server are fixed-width UTC, so text order is time order. +fn thread_activity(thread: &Thread) -> &str { + thread + .latest_user_message_at + .as_deref() + .unwrap_or(&thread.updated_at) +} + +/// Unarchived projects, pinned first, then most recent thread activity (the project's own +/// `updatedAt` when it has no threads), then title. +pub fn build_entries<'a>( + projects: impl IntoIterator, + threads: impl IntoIterator, +) -> Vec { + let mut latest: HashMap<&str, &str> = HashMap::new(); + for thread in threads.into_iter().filter(|t| t.archived_at.is_none()) { + let activity = thread_activity(thread); + let slot = latest.entry(thread.project_id.as_str()).or_insert(activity); + *slot = (*slot).max(activity); + } + let mut ranked: Vec<(&Project, &str)> = projects + .into_iter() + .filter(|project| project.archived_at.is_none()) + .map(|project| { + let activity = latest + .get(project.id.as_str()) + .copied() + .unwrap_or(&project.updated_at); + (project, activity) + }) + .collect(); + ranked.sort_by(|(a, a_at), (b, b_at)| { + b.pinned_at + .is_some() + .cmp(&a.pinned_at.is_some()) + .then_with(|| b_at.cmp(a_at)) + .then_with(|| a.title.to_lowercase().cmp(&b.title.to_lowercase())) + .then_with(|| a.id.cmp(&b.id)) + }); + ranked + .into_iter() + .map(|(project, _)| { + let (initials, color) = monogram_override(project) + .unwrap_or_else(|| (initials(&project.title), color_index(&project.title))); + ProjectEntry { + id: project.id.clone(), + title: project.title.clone(), + initials, + color, + pinned: project.pinned_at.is_some(), + scratch: is_scratch(project), + } + }) + .collect() +} + +/// What the picker lists: no scratch project, the active project leading the unpinned ones +/// (pinned stay first), then the case-insensitive title filter. +pub fn picker_entries<'a>( + entries: &'a [ProjectEntry], + active_id: Option<&str>, + query: &str, +) -> Vec<&'a ProjectEntry> { + let mut listed: Vec<&ProjectEntry> = entries.iter().filter(|entry| !entry.scratch).collect(); + if let Some(at) = listed + .iter() + .position(|entry| Some(entry.id.as_str()) == active_id && !entry.pinned) + { + let active = listed.remove(at); + let pinned = listed.iter().filter(|entry| entry.pinned).count(); + listed.insert(pinned, active); + } + let query = query.trim().to_lowercase(); + listed.retain(|entry| entry.title.to_lowercase().contains(&query)); + listed +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, json}; + + const SNAPSHOT: &str = include_str!("../tests/fixtures/shell-snapshot.json"); + + fn project(id: &str, title: &str, edit: impl FnOnce(&mut Value)) -> Project { + let mut value: Value = serde_json::from_str(SNAPSHOT).expect("json"); + let mut project = value["snapshot"]["projects"][0].take(); + project["id"] = id.into(); + project["title"] = title.into(); + project["archivedAt"] = Value::Null; + project["pinnedAt"] = Value::Null; + project["updatedAt"] = "2026-01-01T00:00:00.000Z".into(); + edit(&mut project); + serde_json::from_value(project).expect("project decodes") + } + + fn thread(project_id: &str, user_message_at: Option<&str>, updated_at: &str) -> Thread { + let mut value: Value = serde_json::from_str(SNAPSHOT).expect("json"); + let mut thread = value["snapshot"]["threads"][0].take(); + thread["id"] = format!("{project_id}-{updated_at}").into(); + thread["projectId"] = project_id.into(); + thread["latestUserMessageAt"] = user_message_at.into(); + thread["updatedAt"] = updated_at.into(); + serde_json::from_value(thread).expect("thread decodes") + } + + fn titles(entries: &[&ProjectEntry]) -> Vec { + entries.iter().map(|entry| entry.title.clone()).collect() + } + + #[test] + fn initials_follow_the_web_monogram() { + for (name, expected) in [ + ("atlas", "AS"), + ("fx-workspace", "FW"), + ("agent0", "A0"), + ("t3code", "T3"), + ("x", "XX"), + (" ", "PR"), + ("--", "PR"), + ] { + assert_eq!(initials(name), expected, "{name}"); + } + } + + #[test] + fn color_is_deterministic_and_case_insensitive() { + assert_eq!(color_index("Atlas"), color_index(" atlas ")); + assert_eq!(color_index(""), color_index("project")); + assert_eq!(COLORS[color_index("a")].0, COLORS[97 % 18].0); + assert!(color_index("anything at all") < COLORS.len()); + } + + #[test] + fn monogram_icon_overrides_the_derived_badge() { + let overridden = project("p", "atlas", |p| { + p["projectIcon"] = json!({"kind": "monogram", "text": "ZZ", "color": "rose"}); + }); + let lucide = project("q", "atlas", |p| { + p["projectIcon"] = json!({"kind": "lucide", "name": "star", "color": "rose"}); + }); + let entries = build_entries([&overridden, &lucide], []); + let by_id = |id: &str| entries.iter().find(|e| e.id == id).unwrap(); + assert_eq!(by_id("p").initials, "ZZ"); + assert_eq!(COLORS[by_id("p").color].0, "rose"); + assert_eq!(by_id("q").initials, "AS"); + } + + #[test] + fn entries_order_pinned_first_then_recent_activity_then_title() { + let projects = [ + project("quiet", "quiet", |p| { + p["updatedAt"] = "2026-03-01T00:00:00.000Z".into() + }), + project("busy", "busy", |_| {}), + project("pinned", "pinned", |p| { + p["pinnedAt"] = "2026-01-01T00:00:00.000Z".into() + }), + project("tie-b", "Bravo", |_| {}), + project("tie-a", "alpha", |_| {}), + project("gone", "gone", |p| { + p["archivedAt"] = "2026-01-01T00:00:00.000Z".into() + }), + ]; + let mut archived = thread("busy", None, "2026-09-01T00:00:00.000Z"); + archived.archived_at = Some("2026-09-02T00:00:00.000Z".into()); + let threads = [ + // The user message time wins over a newer updatedAt. + thread( + "busy", + Some("2026-05-01T00:00:00.000Z"), + "2026-06-01T00:00:00.000Z", + ), + thread("busy", None, "2026-04-01T00:00:00.000Z"), + archived, + ]; + let entries = build_entries(&projects, &threads); + let order: Vec<_> = entries.iter().map(|e| e.title.as_str()).collect(); + assert_eq!(order, ["pinned", "busy", "quiet", "alpha", "Bravo"]); + assert!(entries[0].pinned && !entries[1].pinned); + } + + #[test] + fn scratch_is_the_managed_no_project_folder() { + let scratch = project("s", "No project", |p| { + p["workspaceRoot"] = "/home/me/.t3/userdata/scratch/".into() + }); + let titled_only = project("t", "No project", |p| { + p["workspaceRoot"] = "/work/app".into() + }); + let folder_only = project("f", "scratch", |p| { + p["workspaceRoot"] = "/work/scratch".into() + }); + let entries = build_entries([&scratch, &titled_only, &folder_only], []); + let flags: Vec<_> = ["s", "t", "f"] + .iter() + .map(|id| entries.iter().find(|e| e.id == *id).unwrap().scratch) + .collect(); + assert_eq!(flags, [true, false, false]); + assert_eq!( + titles(&picker_entries(&entries, None, "")).len(), + 2, + "scratch is left out" + ); + } + + #[test] + fn picker_leads_with_the_active_unpinned_project_and_filters() { + let projects = [ + project("pin", "pinned", |p| { + p["pinnedAt"] = "2026-01-01T00:00:00.000Z".into() + }), + project("a", "Alpha", |p| { + p["updatedAt"] = "2026-03-01T00:00:00.000Z".into() + }), + project("b", "Beta", |p| { + p["updatedAt"] = "2026-02-01T00:00:00.000Z".into() + }), + project("c", "Gamma", |_| {}), + ]; + let entries = build_entries(&projects, []); + assert_eq!( + titles(&picker_entries(&entries, None, "")), + ["pinned", "Alpha", "Beta", "Gamma"] + ); + assert_eq!( + titles(&picker_entries(&entries, Some("c"), "")), + ["pinned", "Gamma", "Alpha", "Beta"] + ); + assert_eq!( + titles(&picker_entries(&entries, Some("pin"), "")), + ["pinned", "Alpha", "Beta", "Gamma"] + ); + assert_eq!( + titles(&picker_entries(&entries, Some("c"), " ETA ")), + ["Beta"] + ); + assert!(picker_entries(&entries, None, "zzz").is_empty()); + } +} diff --git a/apps/desktop-gpui/src/shell.rs b/apps/desktop-gpui/src/shell.rs index 650ef0b79dbf..c5471e51e907 100644 --- a/apps/desktop-gpui/src/shell.rs +++ b/apps/desktop-gpui/src/shell.rs @@ -1,6 +1,7 @@ //! The shell read model built from `orchestration.subscribeShell`, mirroring //! `packages/client-runtime/src/state/shellReducer.ts` for what this client shows. +use crate::projects; use crate::status::{ShellView, ThreadRow}; use crate::wire::generated::{ OrchestrationSubscribeShellSuccess as Item, @@ -40,6 +41,7 @@ impl ShellState { .values() .map(|thread| ThreadRow { id: thread.id.clone(), + project_id: thread.project_id.clone(), project: self .projects .get(&thread.project_id) @@ -56,16 +58,10 @@ impl ShellState { .cmp(&a.updated_at) .then_with(|| a.id.cmp(&b.id)) }); - let mut project_titles: Vec = self - .projects - .values() - .map(|project| project.title.clone()) - .collect(); - project_titles.sort_by_key(|title| title.to_lowercase()); ShellView { projects: self.project_count(), threads: self.thread_count(), - project_titles, + project_entries: projects::build_entries(self.projects.values(), self.threads.values()), rows, } } @@ -223,7 +219,12 @@ mod tests { })); let view = state.view(); assert_eq!((view.projects, view.threads), (1, 2)); - assert_eq!(view.project_titles, ["Project"]); + let titles: Vec<_> = view + .project_entries + .iter() + .map(|e| e.title.as_str()) + .collect(); + assert_eq!(titles, ["Project"]); let ids: Vec<_> = view.rows.iter().map(|row| row.id.as_str()).collect(); assert_eq!(ids, ["thread-new", "thread-v2"]); assert!(view.rows.iter().all(|row| row.project == "Project")); diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index 4e5eb66052e5..f2880041decd 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -1,3 +1,4 @@ +use crate::projects::ProjectEntry; use crate::wire::generated::ThreadsEntryStatus; use std::fmt; @@ -20,14 +21,15 @@ pub enum Status { pub struct ShellView { pub projects: usize, pub threads: usize, - /// Project titles in picker order. - pub project_titles: Vec, + /// Unarchived projects in picker order, scratch included. + pub project_entries: Vec, pub rows: Vec, } #[derive(Clone, Debug, PartialEq, Eq)] pub struct ThreadRow { pub id: String, + pub project_id: String, pub project: String, pub title: String, pub branch: Option, diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index a6fb60a6a655..d25b1a639e18 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -1,15 +1,17 @@ +use crate::projects::{self, ProjectEntry}; use crate::status::{ShellView, Status, ThreadRow}; use crate::wire::generated::ThreadsEntryStatus as ThreadStatus; use gpui::{ - Anchor, AnyElement, Context, Entity, Hsla, IntoElement, Keystroke, ParentElement, Render, - Styled, UniformListScrollHandle, Window, div, prelude::*, px, uniform_list, + Anchor, AnyElement, Context, Entity, Focusable as _, FontWeight, Hsla, IntoElement, Keystroke, + ParentElement, Render, Styled, UniformListScrollHandle, Window, div, prelude::*, px, rgb, + uniform_list, }; use gpui_component::{ ActiveTheme, Icon, IconName, Sizable, TitleBar, breadcrumb::{Breadcrumb, BreadcrumbItem}, button::{Button, ButtonVariants}, h_flex, - input::{Input, InputState, Textarea, TextareaState}, + input::{Input, InputEvent, InputState, Textarea, TextareaState}, kbd::Kbd, popover::Popover, tag::Tag, @@ -23,6 +25,7 @@ const ROW_HEIGHT: f32 = 68.; pub struct MainView { status: Status, search: Entity, + project_search: Entity, composer: Entity, list: UniformListScrollHandle, selected_project: Option, @@ -31,9 +34,18 @@ pub struct MainView { impl MainView { pub fn new(status: Status, window: &mut Window, cx: &mut Context) -> Self { + let project_search = + cx.new(|cx| InputState::new(window, cx).placeholder("Search projects...")); + cx.subscribe(&project_search, |_, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }) + .detach(); Self { status, search: cx.new(|cx| InputState::new(window, cx).placeholder("Search threads")), + project_search, composer: cx.new(|cx| { TextareaState::new(window, cx) .placeholder("Ask anything. @ files, $ skills, / commands") @@ -104,8 +116,8 @@ impl MainView { .child(self.status.to_string()) .into_any_element(); }; - let project = active_project(self.selected_project.as_deref(), shell) - .unwrap_or_else(|| "your project".to_owned()); + let active = active_project(self.selected_project.as_deref(), shell); + let project = active.map_or_else(|| "your project".to_owned(), |entry| entry.title.clone()); v_flex() .flex_1() .min_w_0() @@ -127,20 +139,35 @@ impl MainView { .child( centered(cx) .gap_6() - .child(self.hero(&project, shell.project_titles.clone(), cx)) + .child(self.hero( + &project, + active.map(|entry| entry.id.clone()), + shell.project_entries.clone(), + cx, + )) .child(self.composer_box(cx)), ) .into_any_element() } - fn hero(&self, project: &str, titles: Vec, cx: &mut Context) -> impl IntoElement { + fn hero( + &self, + project: &str, + active_id: Option, + entries: Vec, + cx: &mut Context, + ) -> impl IntoElement { let view = cx.entity(); + let search = self.project_search.clone(); let picker = Popover::new("project-picker") .anchor(Anchor::TopCenter) .open(self.picker_open) + .track_focus(&search.focus_handle(cx)) + .p_0() .on_open_change({ - let view = view.clone(); - move |open, _, cx| { + let (view, search) = (view.clone(), search.clone()); + move |open, window, cx| { + search.update(cx, |state, cx| state.set_value("", window, cx)); view.update(cx, |this, cx| { this.picker_open = *open; cx.notify(); @@ -154,32 +181,91 @@ impl MainView { .child(div().text_2xl().child(project.to_owned())), ) .content(move |_, _, cx| { - let hover = cx.theme().list_hover; + let query = search.read(cx).value(); + let listed = projects::picker_entries(&entries, active_id.as_deref(), &query); + let theme = cx.theme(); + let (dark, muted) = (theme.is_dark(), theme.muted_foreground); + let (hover, selected) = (theme.list_hover, theme.list_active); + let mono = theme.mono_font_family.clone(); + let border = theme.border; + let rows = listed.iter().enumerate().map(|(index, entry)| { + let (view, id) = (view.clone(), entry.id.clone()); + let color: Hsla = rgb(entry.color_hex(dark)).into(); + let active = active_id.as_deref() == Some(entry.id.as_str()); + h_flex() + .id(("project", index)) + .gap_2() + .px_2() + .py_1p5() + .items_center() + .rounded_md() + .cursor_pointer() + .when(active, |row| row.bg(selected)) + .hover(move |style| style.bg(hover)) + .child( + div() + .flex() + .flex_shrink_0() + .items_center() + .justify_center() + .size(px(20.)) + .rounded(px(5.)) + .bg(color.opacity(0.14)) + .text_color(color) + .text_size(px(10.)) + .font_weight(FontWeight::BOLD) + .font_family(mono.clone()) + .child(entry.initials.clone()), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .truncate() + .child(entry.title.clone()), + ) + .when(entry.pinned, |row| { + row.child( + Icon::new(gpui_kit_assets::IconName::Pin) + .small() + .text_color(muted), + ) + }) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| { + this.selected_project = Some(id.clone()); + this.picker_open = false; + cx.notify(); + }); + }) + }); v_flex() - .id("project-list") - .w(px(240.)) - .max_h(px(320.)) - .overflow_y_scroll() - .children(titles.iter().enumerate().map(|(index, title)| { - let (view, title) = (view.clone(), title.clone()); - div() - .id(("project", index)) - .px_2() - .py_1() - .rounded_md() - .text_sm() - .truncate() - .cursor_pointer() - .hover(move |style| style.bg(hover)) - .child(title.clone()) - .on_click(move |_, _, cx| { - view.update(cx, |this, cx| { - this.selected_project = Some(title.clone()); - this.picker_open = false; - cx.notify(); - }); - }) - })) + .w(px(400.)) + .child( + h_flex().px_3().border_b_1().border_color(border).child( + Input::new(&search) + .appearance(false) + .prefix(Icon::new(IconName::Search)), + ), + ) + .child( + v_flex() + .id("project-list") + .max_h(px(320.)) + .overflow_y_scroll() + .p_1() + .children(rows) + .when(listed.is_empty(), |list| { + list.child( + div() + .p_2() + .text_sm() + .text_color(muted) + .child("No matching projects."), + ) + }), + ) }); v_flex() .items_center() @@ -236,20 +322,19 @@ impl MainView { } } -/// The picked project while it still exists, else the newest thread's project. -fn active_project(selected: Option<&str>, shell: &ShellView) -> Option { +/// The picked project while it still exists, else the newest thread's project, else the first listed. +fn active_project<'a>(selected: Option<&str>, shell: &'a ShellView) -> Option<&'a ProjectEntry> { + let by_id = |id: &str| shell.project_entries.iter().find(|entry| entry.id == id); selected - .filter(|title| shell.project_titles.iter().any(|known| known == title)) - .map(str::to_owned) + .and_then(by_id) .or_else(|| { shell .rows .iter() - .map(|row| &row.project) - .find(|project| !project.is_empty()) - .cloned() + .filter_map(|row| by_id(&row.project_id)) + .find(|entry| !entry.scratch) }) - .or_else(|| shell.project_titles.first().cloned()) + .or_else(|| shell.project_entries.iter().find(|entry| !entry.scratch)) } fn centered(cx: &Context) -> gpui::Div { @@ -343,10 +428,22 @@ impl Render for MainView { mod tests { use super::*; - fn row(project: &str) -> ThreadRow { + fn entry(id: &str, scratch: bool) -> ProjectEntry { + ProjectEntry { + id: id.to_owned(), + title: id.to_owned(), + initials: "XX".to_owned(), + color: 0, + pinned: false, + scratch, + } + } + + fn row(project_id: &str) -> ThreadRow { ThreadRow { - id: project.to_owned(), - project: project.to_owned(), + id: project_id.to_owned(), + project_id: project_id.to_owned(), + project: project_id.to_owned(), title: String::new(), branch: None, status: ThreadStatus::Idle, @@ -355,24 +452,27 @@ mod tests { } #[test] - fn active_project_prefers_a_live_pick_then_the_newest_thread() { + fn active_project_prefers_a_live_pick_then_the_newest_real_project() { let shell = ShellView { - project_titles: vec!["alpha".into(), "beta".into()], - rows: vec![row(""), row("beta")], + project_entries: vec![ + entry("alpha", false), + entry("none", true), + entry("beta", false), + ], + rows: vec![row("none"), row("gone"), row("beta")], ..ShellView::default() }; - assert_eq!( - active_project(Some("alpha"), &shell).as_deref(), - Some("alpha") - ); - assert_eq!( - active_project(Some("gone"), &shell).as_deref(), - Some("beta") - ); + let id = |selected| active_project(selected, &shell).map(|e| e.id.as_str()); + assert_eq!(id(Some("alpha")), Some("alpha")); + assert_eq!(id(Some("gone")), Some("beta")); + assert_eq!(id(None), Some("beta")); let empty = ShellView { - project_titles: vec!["alpha".into()], + project_entries: vec![entry("none", true), entry("alpha", false)], ..ShellView::default() }; - assert_eq!(active_project(None, &empty).as_deref(), Some("alpha")); + assert_eq!( + active_project(None, &empty).map(|e| e.id.as_str()), + Some("alpha") + ); } } From 1f75651e49ddb2f495e525c4a5b24787bc39cf9f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 5 Nov 2026 01:45:21 -0500 Subject: [PATCH 08/82] feat(desktop-gpui): pin projects from the picker and keep the list steady Hover a picker row to pin or unpin the project through projects.mutate, gated on the server's projectOrganization capability. The picker keeps its order while open and the default project no longer follows thread activity, so rows and the heading stop jumping. The project name is plain heading text that underlines on hover, and the composer gains the attach button. Catalog icons beyond gpui-component's defaults rendered blank, so the app now embeds the extra icons it uses. Signed-off-by: Yordis Prieto --- apps/desktop-gpui/src/assets.rs | 41 ++ apps/desktop-gpui/src/connection.rs | 156 ++++- apps/desktop-gpui/src/main.rs | 91 +-- apps/desktop-gpui/src/projects.rs | 60 ++ apps/desktop-gpui/src/rpc.rs | 9 + apps/desktop-gpui/src/server.rs | 58 +- apps/desktop-gpui/src/status.rs | 10 +- apps/desktop-gpui/src/ui.rs | 189 +++++- apps/desktop-gpui/src/wire/generated.rs | 810 ++++++++++++++++++++++++ apps/desktop-gpui/src/wire/mod.rs | 2 + apps/desktop-gpui/tests/wire_types.rs | 2 +- 11 files changed, 1347 insertions(+), 81 deletions(-) create mode 100644 apps/desktop-gpui/src/assets.rs diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs new file mode 100644 index 000000000000..9cca92b6de3f --- /dev/null +++ b/apps/desktop-gpui/src/assets.rs @@ -0,0 +1,41 @@ +use gpui::{AssetSource, Result, SharedString}; +use std::borrow::Cow; + +// `Assets` carries only gpui-component's default icons; anything else from the catalog renders blank. +gpui_kit_assets::icon_assets!(ExtraIcons, [Paperclip, Pin, PinOff]); + +pub struct AppAssets; + +impl AssetSource for AppAssets { + fn load(&self, path: &str) -> Result>> { + match ExtraIcons.load(path)? { + Some(bytes) => Ok(Some(bytes)), + None => gpui_kit_assets::Assets.load(path), + } + } + + fn list(&self, path: &str) -> Result> { + let mut paths = gpui_kit_assets::Assets.list(path)?; + paths.extend(ExtraIcons.list(path)?); + Ok(paths) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui_kit_assets::IconName; + + #[test] + fn every_catalog_icon_the_app_uses_loads() { + for icon in [ + IconName::Paperclip, + IconName::Pin, + IconName::PinOff, + IconName::Search, + ] { + let loaded = AppAssets.load(&icon.path()).ok().flatten(); + assert!(loaded.is_some(), "{} is not embedded", icon.path()); + } + } +} diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index a7a2f59b0f71..e51b83dfa4b8 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -1,11 +1,12 @@ //! The authenticated RPC WebSocket: connect, keep alive, reconnect. use crate::auth::{self, AuthError, Credential}; -use crate::rpc::{Event, PING_FRAME, Session, SubscribeShell}; +use crate::rpc::{Event, PING_FRAME, ProjectsMutate, Session, SubscribeShell}; use crate::shell::ShellState; use crate::status::{Status, StatusSender, publish}; -use crate::wire::{ShellItem, SubscribeShellPayload}; +use crate::wire::{ProjectMutation, ProjectMutationType, ShellItem, SubscribeShellPayload}; use futures_util::{SinkExt, StreamExt}; +use std::collections::HashMap; use std::time::Duration; use tokio::time::{Instant, MissedTickBehavior, interval, sleep, sleep_until}; use tokio_tungstenite::tungstenite::Message; @@ -16,6 +17,51 @@ const PING_INTERVAL: Duration = Duration::from_secs(5); const PING_TIMEOUT: Duration = Duration::from_secs(15); const ORCHESTRATION_PROTOCOL_VERSION: &str = "2"; +/// What the connection needs to know about the server it dialed. +pub struct Environment { + pub label: String, + /// The server keeps `pinned` from `project.update` (`capabilities.projectOrganization`). + pub project_organization: bool, +} + +/// A request from the window that the connection sends as an RPC. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Action { + SetProjectPinned { project_id: String, pinned: bool }, +} + +pub type ActionSender = async_channel::Sender; +pub type ActionReceiver = async_channel::Receiver; + +impl Action { + fn describe(&self) -> &'static str { + match self { + Self::SetProjectPinned { pinned: true, .. } => "pin project", + Self::SetProjectPinned { pinned: false, .. } => "unpin project", + } + } + + fn payload(&self, command_id: String) -> ProjectMutation { + match self { + Self::SetProjectPinned { project_id, pinned } => ProjectMutation::Variant1 { + archived: None, + auto_pull: None, + command_id, + default_model_selection: None, + default_thread_env_mode: None, + favicon_path: None, + pinned: Some(*pinned), + project_icon: None, + project_id: project_id.clone(), + scripts: None, + title: None, + type_: ProjectMutationType::ProjectUpdate, + workspace_root: None, + }, + } + } +} + /// Doubling delay between attempts, capped (Electron restarts the backend the same way). pub struct Backoff { next: Duration, @@ -67,7 +113,13 @@ fn socket_url(http_base: &str, ticket: &str) -> Result { } /// Connects and stays connected until cancelled, reporting through `status`. -pub async fn run(http_base: &str, credential: &Credential, label: &str, status: &StatusSender) { +pub async fn run( + http_base: &str, + credential: &Credential, + environment: &Environment, + actions: &ActionReceiver, + status: &StatusSender, +) { let client = auth::http_client(); let mut bearer: Option = None; let mut backoff = Backoff::new(); @@ -78,12 +130,13 @@ pub async fn run(http_base: &str, credential: &Credential, label: &str, status: publish( status, Status::Connected { - label: label.to_owned(), + label: environment.label.clone(), + can_pin: environment.project_organization, shell: None, }, ); backoff.reset(); - serve(socket, label, status).await + serve(socket, environment, actions, status).await } Err(message) => message, }; @@ -128,7 +181,12 @@ async fn connect( } /// Subscribes to the shell stream and keeps the socket alive; returns why it ended. -async fn serve(socket: Socket, label: &str, status: &StatusSender) -> String { +async fn serve( + socket: Socket, + environment: &Environment, + actions: &ActionReceiver, + status: &StatusSender, +) -> String { let (mut sink, mut stream) = socket.split(); let mut session = Session::new(); let mut shell = ShellState::default(); @@ -139,6 +197,9 @@ async fn serve(socket: Socket, label: &str, status: &StatusSender) -> String { if let Err(error) = sink.send(Message::text(request)).await { return error.to_string(); } + // Whatever was clicked while disconnected is stale by now. + while actions.try_recv().is_ok() {} + let mut pending: HashMap = HashMap::new(); let mut ping = interval(PING_INTERVAL); ping.set_missed_tick_behavior(MissedTickBehavior::Delay); let mut deadline = Instant::now() + PING_TIMEOUT; @@ -149,6 +210,20 @@ async fn serve(socket: Socket, label: &str, status: &StatusSender) -> String { return error.to_string(); } } + action = actions.recv() => { + let Ok(action) = action else { + return "window closed".to_owned(); + }; + match session.request::(&action.payload(command_id())) { + Ok((id, frame)) => { + pending.insert(id, action.describe()); + if let Err(error) = sink.send(Message::text(frame)).await { + return error.to_string(); + } + } + Err(error) => eprintln!("[rpc] could not {}: {error}", action.describe()), + } + } _ = sleep_until(deadline) => { return format!("server stopped responding for {}s", PING_TIMEOUT.as_secs()); } @@ -170,7 +245,10 @@ async fn serve(socket: Socket, label: &str, status: &StatusSender) -> String { } } for event in reaction.events { - if let Some(reason) = handle(event, &mut shell, label, status) { + if settle(&mut pending, &event) { + continue; + } + if let Some(reason) = handle(event, &mut shell, environment, status) { return reason; } } @@ -181,11 +259,33 @@ async fn serve(socket: Socket, label: &str, status: &StatusSender) -> String { } } +fn command_id() -> String { + let mut bytes = [0u8; 8]; + // A failed read leaves zeros, and the server rejects a repeated command id rather than applying it twice. + let _ = getrandom::fill(&mut bytes); + format!("gpui-{}", hex::encode(bytes)) +} + +/// Consumes the outcome of an action's request, logging a failure; true when `event` was one. +fn settle(pending: &mut HashMap, event: &Event) -> bool { + match event { + Event::Done { request_id, .. } => pending.remove(request_id).is_some(), + Event::Failed { + request_id, + message, + } => pending.remove(request_id).is_some_and(|what| { + eprintln!("[rpc] could not {what}: {message}"); + true + }), + _ => false, + } +} + /// Applies one event; `Some(reason)` means the subscription is over and the socket should reopen. fn handle( event: Event, shell: &mut ShellState, - label: &str, + environment: &Environment, status: &StatusSender, ) -> Option { match event { @@ -195,7 +295,8 @@ fn handle( publish( status, Status::Connected { - label: label.to_owned(), + label: environment.label.clone(), + can_pin: environment.project_organization, shell: Some(shell.view()), }, ); @@ -224,6 +325,43 @@ mod tests { assert_eq!(backoff.next_delay().as_millis(), 500); } + #[test] + fn pin_action_builds_a_project_update_mutation() { + let pin = Action::SetProjectPinned { + project_id: "p1".to_owned(), + pinned: true, + }; + let value = serde_json::to_value(pin.payload("c1".to_owned())).expect("json"); + assert_eq!( + value, + serde_json::json!({ + "type": "project.update", + "commandId": "c1", + "projectId": "p1", + "pinned": true, + }) + ); + let unpin = Action::SetProjectPinned { + project_id: "p1".to_owned(), + pinned: false, + }; + let value = serde_json::to_value(unpin.payload("c2".to_owned())).expect("json"); + assert_eq!(value["pinned"], false); + } + + #[test] + fn settle_consumes_only_the_outcome_of_a_pending_action() { + let mut pending = HashMap::from([("7".to_owned(), "pin project")]); + let failed = |id: &str| Event::Failed { + request_id: id.to_owned(), + message: "no".to_owned(), + }; + assert!(!settle(&mut pending, &failed("0"))); + assert!(settle(&mut pending, &failed("7"))); + assert!(pending.is_empty()); + assert!(!settle(&mut pending, &failed("7"))); + } + #[test] fn socket_url_carries_protocol_and_escaped_ticket() { let url = socket_url("http://127.0.0.1:13773", "a b&c").expect("url"); diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 2c146d5e077a..3c107ae90fcd 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -1,3 +1,4 @@ +mod assets; mod auth; mod config; mod connection; @@ -9,10 +10,10 @@ mod status; mod ui; mod wire; +use assets::AppAssets; use config::{Attach, Config}; use gpui::{App, AppContext as _, Bounds, WindowBounds, WindowOptions, px, size}; use gpui_component::{Root, Theme, ThemeMode, TitleBar}; -use gpui_kit_assets::Assets; use gpui_platform::application; use server::Supervisor; use status::Status; @@ -26,6 +27,7 @@ fn main() { .build() .expect("failed to start the async runtime"); let (status_tx, status_rx) = async_channel::unbounded(); + let (action_tx, action_rx) = async_channel::unbounded(); let attach = Attach::from_env(); let supervisor = if attach.is_some() { None @@ -34,6 +36,7 @@ fn main() { Rc::new(Supervisor::start( runtime.handle(), config, + action_rx.clone(), status_tx.clone(), )) }) @@ -42,9 +45,16 @@ fn main() { runtime.spawn(async move { let client = auth::http_client(); match server::wait_until_ready(&client, &attach.http_base).await { - Ok(label) => { + Ok(environment) => { let credential = auth::Credential::Pairing(attach.pairing_token); - connection::run(&attach.http_base, &credential, &label, &status_tx).await; + connection::run( + &attach.http_base, + &credential, + &environment, + &action_rx, + &status_tx, + ) + .await; } Err(message) => status::publish(&status_tx, Status::Failed(message)), } @@ -56,42 +66,45 @@ fn main() { Status::NotConnected }; - application().with_assets(Assets).run(move |cx: &mut App| { - gpui_component::init(cx); - Theme::change(ThemeMode::Dark, None, cx); - if let Some(supervisor) = supervisor { - // The grace period outlasts gpui's quit timeout, so block here instead of awaiting. - cx.on_app_quit(move |_| { - supervisor.stop(); - async {} - }) - .detach(); - } - let bounds = Bounds::centered(None, size(px(960.), px(640.)), cx); - cx.open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - ..TitleBar::window_options() - }, - |window, cx| { - let view = cx.new(|cx| { - cx.spawn(async move |this, cx| { - while let Ok(status) = status_rx.recv().await { - let updated = this - .update(cx, |view: &mut MainView, cx| view.set_status(status, cx)); - if updated.is_err() { - break; + application() + .with_assets(AppAssets) + .run(move |cx: &mut App| { + gpui_component::init(cx); + Theme::change(ThemeMode::Dark, None, cx); + if let Some(supervisor) = supervisor { + // The grace period outlasts gpui's quit timeout, so block here instead of awaiting. + cx.on_app_quit(move |_| { + supervisor.stop(); + async {} + }) + .detach(); + } + let bounds = Bounds::centered(None, size(px(960.), px(640.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..TitleBar::window_options() + }, + |window, cx| { + let view = cx.new(|cx| { + cx.spawn(async move |this, cx| { + while let Ok(status) = status_rx.recv().await { + let updated = this.update(cx, |view: &mut MainView, cx| { + view.set_status(status, cx) + }); + if updated.is_err() { + break; + } } - } - }) - .detach(); - MainView::new(initial, window, cx) - }); - cx.new(|cx| Root::new(view, window, cx)) - }, - ) - .expect("failed to open the main window"); - cx.activate(true); - }); + }) + .detach(); + MainView::new(initial, action_tx, window, cx) + }); + cx.new(|cx| Root::new(view, window, cx)) + }, + ) + .expect("failed to open the main window"); + cx.activate(true); + }); drop(runtime); } diff --git a/apps/desktop-gpui/src/projects.rs b/apps/desktop-gpui/src/projects.rs index 7f0186db0913..b5bcc13f8b87 100644 --- a/apps/desktop-gpui/src/projects.rs +++ b/apps/desktop-gpui/src/projects.rs @@ -50,6 +50,12 @@ impl ProjectEntry { } } +/// The pin button's tooltip, with the default `thread.pin` shortcut. +pub fn pin_tooltip(name: &str, pinned: bool) -> String { + let verb = if pinned { "Unpin" } else { "Pin" }; + format!("{verb} {name} (\u{21e7}\u{2318}P)") +} + fn words(text: &str) -> Vec<&str> { text.split(|c: char| !c.is_alphanumeric()) .filter(|word| !word.is_empty()) @@ -194,6 +200,23 @@ pub fn picker_entries<'a>( listed } +/// Rows in the order they had when the picker opened (`useOrderWhileOpen`): ids that left drop +/// out, ids that arrived follow in their current order. +pub fn order_while_open<'a>( + listed: Vec<&'a ProjectEntry>, + opened_with: &[String], +) -> Vec<&'a ProjectEntry> { + let position = |entry: &ProjectEntry| { + opened_with + .iter() + .position(|id| *id == entry.id) + .unwrap_or(opened_with.len()) + }; + let mut ordered = listed; + ordered.sort_by_key(|entry| position(entry)); + ordered +} + #[cfg(test)] mod tests { use super::*; @@ -201,6 +224,43 @@ mod tests { const SNAPSHOT: &str = include_str!("../tests/fixtures/shell-snapshot.json"); + #[test] + fn order_while_open_keeps_the_opening_order_and_appends_new_rows() { + let entry = |id: &str, pinned: bool| ProjectEntry { + id: id.to_owned(), + title: id.to_owned(), + initials: String::new(), + color: 0, + pinned, + scratch: false, + }; + let opened = ["a", "b", "c"].map(str::to_owned); + let now = [entry("d", false), entry("c", true), entry("a", false)]; + let ids = |listed: Vec<&ProjectEntry>| -> Vec { + listed.into_iter().map(|e| e.id.clone()).collect() + }; + assert_eq!( + ids(order_while_open(now.iter().collect(), &opened)), + ["a", "c", "d"] + ); + assert_eq!( + ids(order_while_open(now.iter().collect(), &[])), + ["d", "c", "a"] + ); + } + + #[test] + fn pin_tooltip_names_the_action_and_shortcut() { + assert_eq!( + pin_tooltip("t3code", false), + "Pin t3code (\u{21e7}\u{2318}P)" + ); + assert_eq!( + pin_tooltip("t3code", true), + "Unpin t3code (\u{21e7}\u{2318}P)" + ); + } + fn project(id: &str, title: &str, edit: impl FnOnce(&mut Value)) -> Project { let mut value: Value = serde_json::from_str(SNAPSHOT).expect("json"); let mut project = value["snapshot"]["projects"][0].take(); diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs index dd86ded87283..f7bcc189ed91 100644 --- a/apps/desktop-gpui/src/rpc.rs +++ b/apps/desktop-gpui/src/rpc.rs @@ -26,6 +26,15 @@ impl Method for SubscribeShell { type Success = wire::ShellItem; } +/// `projects.mutate`, which carries `project.update` among its mutations. +pub struct ProjectsMutate; + +impl Method for ProjectsMutate { + const TAG: &'static str = "projects.mutate"; + type Payload = wire::ProjectMutation; + type Success = wire::ProjectsMutateSuccess; +} + /// Request ids are bigints on the wire, so they travel as strings. fn id_text<'de, D: Deserializer<'de>>(deserializer: D) -> Result { match Value::deserialize(deserializer)? { diff --git a/apps/desktop-gpui/src/server.rs b/apps/desktop-gpui/src/server.rs index e96963438d94..a752fadefd30 100644 --- a/apps/desktop-gpui/src/server.rs +++ b/apps/desktop-gpui/src/server.rs @@ -5,7 +5,7 @@ use crate::auth; use crate::config::{Config, ServerCommand}; -use crate::connection::{self, Backoff}; +use crate::connection::{self, ActionReceiver, Backoff, Environment}; use crate::status::{Status, StatusSender, publish}; use serde::{Deserialize, Serialize}; use std::io; @@ -162,10 +162,21 @@ fn forward_output(stream: impl AsyncRead + Unpin + Send + 'static) { #[derive(Deserialize)] struct Descriptor { label: String, + #[serde(default)] + capabilities: Capabilities, } -/// Polls until the descriptor answers, returning the environment label. -pub async fn wait_until_ready(client: &reqwest::Client, http_base: &str) -> Result { +#[derive(Default, Deserialize)] +struct Capabilities { + #[serde(rename = "projectOrganization", default)] + project_organization: bool, +} + +/// Polls until the descriptor answers, returning what the connection needs from it. +pub async fn wait_until_ready( + client: &reqwest::Client, + http_base: &str, +) -> Result { let url = format!("{http_base}{READINESS_PATH}"); let deadline = Instant::now() + READINESS_TIMEOUT; loop { @@ -178,7 +189,10 @@ pub async fn wait_until_ready(client: &reqwest::Client, http_base: &str) -> Resu && response.status().is_success() && let Ok(descriptor) = response.json::().await { - return Ok(descriptor.label); + return Ok(Environment { + label: descriptor.label, + project_organization: descriptor.capabilities.project_organization, + }); } if Instant::now() >= deadline { return Err(format!( @@ -214,12 +228,24 @@ async fn sleep_or_shutdown(delay: Duration, shutdown: &mut watch::Receiver } } -async fn session(config: &Config, secret: &str, status: &StatusSender) -> String { +async fn session( + config: &Config, + secret: &str, + actions: &ActionReceiver, + status: &StatusSender, +) -> String { let client = auth::http_client(); match wait_until_ready(&client, &config.http_base()).await { - Ok(label) => { + Ok(environment) => { let credential = auth::Credential::DesktopSecret(secret.to_owned()); - connection::run(&config.http_base(), &credential, &label, status).await; + connection::run( + &config.http_base(), + &credential, + &environment, + actions, + status, + ) + .await; unreachable_session() } Err(message) => message, @@ -232,7 +258,12 @@ fn unreachable_session() -> String { } /// Runs the server and its connection until shutdown, restarting after crashes. -pub async fn supervise(config: Config, status: StatusSender, mut shutdown: watch::Receiver) { +pub async fn supervise( + config: Config, + actions: ActionReceiver, + status: StatusSender, + mut shutdown: watch::Receiver, +) { let mut backoff = Backoff::new(); loop { publish(&status, Status::Starting); @@ -285,7 +316,7 @@ pub async fn supervise(config: Config, status: StatusSender, mut shutdown: watch None => "Server exited unexpectedly".to_owned(), } } - message = session(&config, &secret, &status) => { + message = session(&config, &secret, &actions, &status) => { terminate(&mut child).await; message } @@ -307,11 +338,16 @@ pub struct Supervisor { } impl Supervisor { - pub fn start(runtime: &tokio::runtime::Handle, config: Config, status: StatusSender) -> Self { + pub fn start( + runtime: &tokio::runtime::Handle, + config: Config, + actions: ActionReceiver, + status: StatusSender, + ) -> Self { let (shutdown, shutdown_rx) = watch::channel(false); let (done_tx, done_rx) = std::sync::mpsc::channel(); runtime.spawn(async move { - supervise(config, status, shutdown_rx).await; + supervise(config, actions, status, shutdown_rx).await; let _ = done_tx.send(()); }); Self { diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index f2880041decd..0274a48ef221 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -11,6 +11,8 @@ pub enum Status { /// Socket is up. `shell` stays `None` until the first shell snapshot lands. Connected { label: String, + /// The server keeps project pins, so the window offers them. + can_pin: bool, shell: Option, }, Failed(String), @@ -51,12 +53,15 @@ impl fmt::Display for Status { Self::NotConnected => f.write_str("Not connected"), Self::Starting => f.write_str("Starting server..."), Self::Connecting => f.write_str("Connecting..."), - Self::Connected { label, shell: None } => write!(f, "Connected to {label}"), + Self::Connected { + label, shell: None, .. + } => write!(f, "Connected to {label}"), Self::Connected { label, shell: Some(ShellView { projects, threads, .. }), + .. } => write!( f, "Connected to {label} \u{b7} {projects} {} \u{b7} {threads} {}", @@ -84,11 +89,13 @@ mod tests { let label = "Local".to_owned(); let waiting = Status::Connected { label: label.clone(), + can_pin: false, shell: None, }; assert_eq!(waiting.to_string(), "Connected to Local"); let known = Status::Connected { label: label.clone(), + can_pin: false, shell: Some(ShellView { projects: 2, threads: 14, @@ -101,6 +108,7 @@ mod tests { ); let single = Status::Connected { label, + can_pin: false, shell: Some(ShellView { projects: 1, threads: 1, diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index d25b1a639e18..78720aa53f5d 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -1,13 +1,14 @@ +use crate::connection::{Action, ActionSender}; use crate::projects::{self, ProjectEntry}; use crate::status::{ShellView, Status, ThreadRow}; use crate::wire::generated::ThreadsEntryStatus as ThreadStatus; use gpui::{ - Anchor, AnyElement, Context, Entity, Focusable as _, FontWeight, Hsla, IntoElement, Keystroke, - ParentElement, Render, Styled, UniformListScrollHandle, Window, div, prelude::*, px, rgb, - uniform_list, + Anchor, AnyElement, App, Context, Entity, Focusable as _, FontWeight, Hsla, IntoElement, + Keystroke, ParentElement, Render, RenderOnce, Styled, UniformListScrollHandle, Window, div, + prelude::*, px, rgb, uniform_list, }; use gpui_component::{ - ActiveTheme, Icon, IconName, Sizable, TitleBar, + ActiveTheme, Icon, IconName, Selectable, Sizable, TitleBar, breadcrumb::{Breadcrumb, BreadcrumbItem}, button::{Button, ButtonVariants}, h_flex, @@ -30,10 +31,17 @@ pub struct MainView { list: UniformListScrollHandle, selected_project: Option, picker_open: bool, + picker_order: Option>, + actions: ActionSender, } impl MainView { - pub fn new(status: Status, window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + status: Status, + actions: ActionSender, + window: &mut Window, + cx: &mut Context, + ) -> Self { let project_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search projects...")); cx.subscribe(&project_search, |_, _, event: &InputEvent, cx| { @@ -54,11 +62,20 @@ impl MainView { list: UniformListScrollHandle::new(), selected_project: None, picker_open: false, + picker_order: None, + actions, } } pub fn set_status(&mut self, status: Status, cx: &mut Context) { self.status = status; + // Thread activity reorders recency on every delta, so the default must not follow it. + if self.selected_project.is_none() { + self.selected_project = self + .shell() + .and_then(|shell| active_project(None, shell)) + .map(|entry| entry.id.clone()); + } cx.notify(); } @@ -69,6 +86,18 @@ impl MainView { } } + fn can_pin(&self) -> bool { + matches!(&self.status, Status::Connected { can_pin: true, .. }) + } + + fn picker_ids(&self) -> Option> { + let shell = self.shell()?; + let active = active_project(self.selected_project.as_deref(), shell); + let entries = &shell.project_entries; + let listed = projects::picker_entries(entries, active.map(|e| e.id.as_str()), ""); + Some(listed.into_iter().map(|entry| entry.id.clone()).collect()) + } + fn environment(&self) -> String { match &self.status { Status::Connected { label, .. } => label.clone(), @@ -158,6 +187,8 @@ impl MainView { cx: &mut Context, ) -> impl IntoElement { let view = cx.entity(); + let (actions, can_pin) = (self.actions.clone(), self.can_pin()); + let order = self.picker_order.clone(); let search = self.project_search.clone(); let picker = Popover::new("project-picker") .anchor(Anchor::TopCenter) @@ -170,19 +201,18 @@ impl MainView { search.update(cx, |state, cx| state.set_value("", window, cx)); view.update(cx, |this, cx| { this.picker_open = *open; + this.picker_order = open.then(|| this.picker_ids()).flatten(); cx.notify(); }); } }) - .trigger( - Button::new("project") - .ghost() - .tooltip(project.to_owned()) - .child(div().text_2xl().child(project.to_owned())), - ) + .trigger(ProjectTrigger::new(project)) .content(move |_, _, cx| { let query = search.read(cx).value(); - let listed = projects::picker_entries(&entries, active_id.as_deref(), &query); + let mut listed = projects::picker_entries(&entries, active_id.as_deref(), &query); + if let Some(order) = &order { + listed = projects::order_while_open(listed, order); + } let theme = cx.theme(); let (dark, muted) = (theme.is_dark(), theme.muted_foreground); let (hover, selected) = (theme.list_hover, theme.list_active); @@ -190,10 +220,13 @@ impl MainView { let border = theme.border; let rows = listed.iter().enumerate().map(|(index, entry)| { let (view, id) = (view.clone(), entry.id.clone()); + let (actions, pin_id) = (actions.clone(), entry.id.clone()); + let pinned = entry.pinned; let color: Hsla = rgb(entry.color_hex(dark)).into(); let active = active_id.as_deref() == Some(entry.id.as_str()); h_flex() .id(("project", index)) + .group(PICKER_ROW_GROUP) .gap_2() .px_2() .py_1p5() @@ -225,7 +258,10 @@ impl MainView { .truncate() .child(entry.title.clone()), ) - .when(entry.pinned, |row| { + .when(can_pin, |row| { + row.child(pin_button(index, entry, actions, pin_id)) + }) + .when(!can_pin && pinned, |row| { row.child( Icon::new(gpui_kit_assets::IconName::Pin) .small() @@ -311,17 +347,130 @@ impl MainView { .bordered(false), ) .child( - h_flex().justify_end().child( - Button::new("send") - .primary() - .rounded_full() - .icon(IconName::ArrowUp) - .on_click(|_, _, _| {}), - ), + h_flex() + .justify_between() + .child( + Button::new("attach") + .ghost() + .small() + .icon(gpui_kit_assets::IconName::Paperclip) + .tooltip("Attach files") + .on_click(|_, _, _| {}), + ) + .child( + Button::new("send") + .primary() + .rounded_full() + .icon(IconName::ArrowUp) + .on_click(|_, _, _| {}), + ), ) } } +/// The project name as plain heading text that underlines on hover and while its picker is open. +#[derive(IntoElement)] +struct ProjectTrigger { + name: String, + open: bool, +} + +impl ProjectTrigger { + fn new(name: &str) -> Self { + Self { + name: name.to_owned(), + open: false, + } + } +} + +impl Selectable for ProjectTrigger { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } + + fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + fn is_open(&self) -> bool { + self.open + } +} + +impl RenderOnce for ProjectTrigger { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let tooltip = self.name.clone(); + div() + .id("project") + .ml_2() + .min_w_0() + .max_w_full() + .truncate() + .text_2xl() + .cursor_pointer() + .when(self.open, |name| name.underline()) + .hover(|style| style.underline()) + .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) + .child(self.name) + } +} + +const PICKER_ROW_GROUP: &str = "project-row"; + +/// Shown on row hover, and always for a pinned project. A click toggles the pin and, because +/// `Button` stops propagation, does not select the row. +fn pin_button( + index: usize, + entry: &ProjectEntry, + actions: ActionSender, + project_id: String, +) -> impl IntoElement + use<> { + let pinned = entry.pinned; + let icon = |name| Icon::new(name).small(); + Button::new(("pin", index)) + .ghost() + .xsmall() + .tooltip(projects::pin_tooltip(&entry.title, pinned)) + .when(!pinned, |button| { + button + .invisible() + .group_hover(PICKER_ROW_GROUP, |style| style.visible()) + }) + .child(if pinned { + div() + .group("pin-icon") + .child( + div() + .group_hover("pin-icon", |style| style.invisible()) + .child(icon(gpui_kit_assets::IconName::Pin)), + ) + .child( + div() + .absolute() + .invisible() + .group_hover("pin-icon", |style| style.visible()) + .child(icon(gpui_kit_assets::IconName::PinOff)), + ) + } else { + div().child(icon(gpui_kit_assets::IconName::Pin)) + }) + .on_click(move |_, _, _| { + let action = Action::SetProjectPinned { + project_id: project_id.clone(), + pinned: !pinned, + }; + if actions.try_send(action).is_err() { + eprintln!("[ui] could not queue the pin request"); + } + }) +} + /// The picked project while it still exists, else the newest thread's project, else the first listed. fn active_project<'a>(selected: Option<&str>, shell: &'a ShellView) -> Option<&'a ProjectEntry> { let by_id = |id: &str| shell.project_entries.iter().find(|entry| entry.id == id); diff --git a/apps/desktop-gpui/src/wire/generated.rs b/apps/desktop-gpui/src/wire/generated.rs index 829e3bf68a6d..2e7d70feef5e 100644 --- a/apps/desktop-gpui/src/wire/generated.rs +++ b/apps/desktop-gpui/src/wire/generated.rs @@ -2097,6 +2097,67 @@ impl ::std::convert::TryFrom<::std::string::String> for ProjectIconKind { value.parse() } } +///`ProjectMutationErrorEncoded` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct ProjectMutationErrorEncoded { + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub cause: ::std::option::Option<::serde_json::Value>, + #[serde(rename = "commandId")] + pub command_id: ::std::string::String, + pub message: ::std::string::String, + #[serde(rename = "_tag")] + pub tag: ProjectMutationErrorEncodedTag, +} +///`ProjectMutationErrorEncodedTag` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ProjectMutationErrorEncodedTag { + ProjectMutationError, +} +impl ::std::fmt::Display for ProjectMutationErrorEncodedTag { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::ProjectMutationError => f.write_str("ProjectMutationError"), + } + } +} +impl ::std::str::FromStr for ProjectMutationErrorEncodedTag { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "ProjectMutationError" => Ok(Self::ProjectMutationError), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ProjectMutationErrorEncodedTag { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> for ProjectMutationErrorEncodedTag { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} ///`Projects` #[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] #[serde(transparent)] @@ -2222,6 +2283,755 @@ for ProjectsEntryDefaultThreadEnvMode { value.parse() } } +///`ProjectsMutateError` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +#[serde(untagged)] +pub enum ProjectsMutateError { + ProjectMutationErrorEncoded(ProjectMutationErrorEncoded), + EnvironmentAuthorizationErrorEncoded(EnvironmentAuthorizationErrorEncoded), +} +impl ::std::convert::From for ProjectsMutateError { + fn from(value: ProjectMutationErrorEncoded) -> Self { + Self::ProjectMutationErrorEncoded(value) + } +} +impl ::std::convert::From for ProjectsMutateError { + fn from(value: EnvironmentAuthorizationErrorEncoded) -> Self { + Self::EnvironmentAuthorizationErrorEncoded(value) + } +} +///`ProjectsMutatePayload` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +#[serde(untagged)] +pub enum ProjectsMutatePayload { + Variant0 { + #[serde(rename = "commandId")] + command_id: ::std::string::String, + #[serde( + rename = "createWorkspaceRootIfMissing", + skip_serializing_if = "::std::option::Option::is_none" + )] + create_workspace_root_if_missing: ::std::option::Option, + #[serde( + rename = "defaultModelSelection", + skip_serializing_if = "::std::option::Option::is_none" + )] + default_model_selection: ::std::option::Option, + #[serde(rename = "projectId")] + project_id: ::std::string::String, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + scripts: ::std::option::Option, + title: ::std::string::String, + #[serde(rename = "type")] + type_: ProjectsMutatePayloadVariant0Type, + #[serde(rename = "workspaceRoot")] + workspace_root: ::std::string::String, + }, + Variant1 { + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + archived: ::std::option::Option, + #[serde( + rename = "autoPull", + skip_serializing_if = "::std::option::Option::is_none" + )] + auto_pull: ::std::option::Option, + #[serde(rename = "commandId")] + command_id: ::std::string::String, + #[serde( + rename = "defaultModelSelection", + skip_serializing_if = "::std::option::Option::is_none" + )] + default_model_selection: ::std::option::Option, + #[serde( + rename = "defaultThreadEnvMode", + skip_serializing_if = "::std::option::Option::is_none" + )] + default_thread_env_mode: ::std::option::Option< + ProjectsMutatePayloadVariant1DefaultThreadEnvMode, + >, + #[serde( + rename = "faviconPath", + skip_serializing_if = "::std::option::Option::is_none" + )] + favicon_path: ::std::option::Option<::std::string::String>, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pinned: ::std::option::Option, + #[serde( + rename = "projectIcon", + skip_serializing_if = "::std::option::Option::is_none" + )] + project_icon: ::std::option::Option, + #[serde(rename = "projectId")] + project_id: ::std::string::String, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + scripts: ::std::option::Option, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + title: ::std::option::Option<::std::string::String>, + #[serde(rename = "type")] + type_: ProjectsMutatePayloadVariant1Type, + #[serde( + rename = "workspaceRoot", + skip_serializing_if = "::std::option::Option::is_none" + )] + workspace_root: ::std::option::Option<::std::string::String>, + }, + Variant2 { + #[serde(rename = "commandId")] + command_id: ::std::string::String, + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + force: ::std::option::Option, + #[serde(rename = "projectId")] + project_id: ::std::string::String, + #[serde(rename = "type")] + type_: ProjectsMutatePayloadVariant2Type, + }, +} +///`ProjectsMutatePayloadVariant0Type` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ProjectsMutatePayloadVariant0Type { + #[serde(rename = "project.create")] + ProjectCreate, +} +impl ::std::fmt::Display for ProjectsMutatePayloadVariant0Type { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::ProjectCreate => f.write_str("project.create"), + } + } +} +impl ::std::str::FromStr for ProjectsMutatePayloadVariant0Type { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "project.create" => Ok(Self::ProjectCreate), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ProjectsMutatePayloadVariant0Type { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for ProjectsMutatePayloadVariant0Type { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ProjectsMutatePayloadVariant1DefaultThreadEnvMode` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ProjectsMutatePayloadVariant1DefaultThreadEnvMode { + #[serde(rename = "local")] + Local, + #[serde(rename = "worktree")] + Worktree, +} +impl ::std::fmt::Display for ProjectsMutatePayloadVariant1DefaultThreadEnvMode { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Local => f.write_str("local"), + Self::Worktree => f.write_str("worktree"), + } + } +} +impl ::std::str::FromStr for ProjectsMutatePayloadVariant1DefaultThreadEnvMode { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "local" => Ok(Self::Local), + "worktree" => Ok(Self::Worktree), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> +for ProjectsMutatePayloadVariant1DefaultThreadEnvMode { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for ProjectsMutatePayloadVariant1DefaultThreadEnvMode { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ProjectsMutatePayloadVariant1ProjectIcon` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +#[serde(untagged)] +pub enum ProjectsMutatePayloadVariant1ProjectIcon { + Variant0 { + color: ProjectsMutatePayloadVariant1ProjectIconVariant0Color, + kind: ProjectsMutatePayloadVariant1ProjectIconVariant0Kind, + name: ::std::string::String, + }, + Variant1 { + emoji: ::std::string::String, + kind: ProjectsMutatePayloadVariant1ProjectIconVariant1Kind, + }, + Variant2(ProjectIcon2), + Variant3, +} +impl ::std::convert::From for ProjectsMutatePayloadVariant1ProjectIcon { + fn from(value: ProjectIcon2) -> Self { + Self::Variant2(value) + } +} +///`ProjectsMutatePayloadVariant1ProjectIconVariant0Color` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ProjectsMutatePayloadVariant1ProjectIconVariant0Color { + #[serde(rename = "gray")] + Gray, + #[serde(rename = "red")] + Red, + #[serde(rename = "orange")] + Orange, + #[serde(rename = "amber")] + Amber, + #[serde(rename = "yellow")] + Yellow, + #[serde(rename = "lime")] + Lime, + #[serde(rename = "green")] + Green, + #[serde(rename = "emerald")] + Emerald, + #[serde(rename = "teal")] + Teal, + #[serde(rename = "cyan")] + Cyan, + #[serde(rename = "sky")] + Sky, + #[serde(rename = "blue")] + Blue, + #[serde(rename = "indigo")] + Indigo, + #[serde(rename = "violet")] + Violet, + #[serde(rename = "purple")] + Purple, + #[serde(rename = "fuchsia")] + Fuchsia, + #[serde(rename = "pink")] + Pink, + #[serde(rename = "rose")] + Rose, +} +impl ::std::fmt::Display for ProjectsMutatePayloadVariant1ProjectIconVariant0Color { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Gray => f.write_str("gray"), + Self::Red => f.write_str("red"), + Self::Orange => f.write_str("orange"), + Self::Amber => f.write_str("amber"), + Self::Yellow => f.write_str("yellow"), + Self::Lime => f.write_str("lime"), + Self::Green => f.write_str("green"), + Self::Emerald => f.write_str("emerald"), + Self::Teal => f.write_str("teal"), + Self::Cyan => f.write_str("cyan"), + Self::Sky => f.write_str("sky"), + Self::Blue => f.write_str("blue"), + Self::Indigo => f.write_str("indigo"), + Self::Violet => f.write_str("violet"), + Self::Purple => f.write_str("purple"), + Self::Fuchsia => f.write_str("fuchsia"), + Self::Pink => f.write_str("pink"), + Self::Rose => f.write_str("rose"), + } + } +} +impl ::std::str::FromStr for ProjectsMutatePayloadVariant1ProjectIconVariant0Color { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "gray" => Ok(Self::Gray), + "red" => Ok(Self::Red), + "orange" => Ok(Self::Orange), + "amber" => Ok(Self::Amber), + "yellow" => Ok(Self::Yellow), + "lime" => Ok(Self::Lime), + "green" => Ok(Self::Green), + "emerald" => Ok(Self::Emerald), + "teal" => Ok(Self::Teal), + "cyan" => Ok(Self::Cyan), + "sky" => Ok(Self::Sky), + "blue" => Ok(Self::Blue), + "indigo" => Ok(Self::Indigo), + "violet" => Ok(Self::Violet), + "purple" => Ok(Self::Purple), + "fuchsia" => Ok(Self::Fuchsia), + "pink" => Ok(Self::Pink), + "rose" => Ok(Self::Rose), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> +for ProjectsMutatePayloadVariant1ProjectIconVariant0Color { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for ProjectsMutatePayloadVariant1ProjectIconVariant0Color { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ProjectsMutatePayloadVariant1ProjectIconVariant0Kind` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ProjectsMutatePayloadVariant1ProjectIconVariant0Kind { + #[serde(rename = "lucide")] + Lucide, +} +impl ::std::fmt::Display for ProjectsMutatePayloadVariant1ProjectIconVariant0Kind { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Lucide => f.write_str("lucide"), + } + } +} +impl ::std::str::FromStr for ProjectsMutatePayloadVariant1ProjectIconVariant0Kind { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "lucide" => Ok(Self::Lucide), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> +for ProjectsMutatePayloadVariant1ProjectIconVariant0Kind { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for ProjectsMutatePayloadVariant1ProjectIconVariant0Kind { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ProjectsMutatePayloadVariant1ProjectIconVariant1Kind` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ProjectsMutatePayloadVariant1ProjectIconVariant1Kind { + #[serde(rename = "emoji")] + Emoji, +} +impl ::std::fmt::Display for ProjectsMutatePayloadVariant1ProjectIconVariant1Kind { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Emoji => f.write_str("emoji"), + } + } +} +impl ::std::str::FromStr for ProjectsMutatePayloadVariant1ProjectIconVariant1Kind { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "emoji" => Ok(Self::Emoji), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> +for ProjectsMutatePayloadVariant1ProjectIconVariant1Kind { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for ProjectsMutatePayloadVariant1ProjectIconVariant1Kind { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ProjectsMutatePayloadVariant1Type` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ProjectsMutatePayloadVariant1Type { + #[serde(rename = "project.update")] + ProjectUpdate, +} +impl ::std::fmt::Display for ProjectsMutatePayloadVariant1Type { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::ProjectUpdate => f.write_str("project.update"), + } + } +} +impl ::std::str::FromStr for ProjectsMutatePayloadVariant1Type { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "project.update" => Ok(Self::ProjectUpdate), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ProjectsMutatePayloadVariant1Type { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for ProjectsMutatePayloadVariant1Type { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ProjectsMutatePayloadVariant2Type` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ProjectsMutatePayloadVariant2Type { + #[serde(rename = "project.delete")] + ProjectDelete, +} +impl ::std::fmt::Display for ProjectsMutatePayloadVariant2Type { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::ProjectDelete => f.write_str("project.delete"), + } + } +} +impl ::std::str::FromStr for ProjectsMutatePayloadVariant2Type { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "project.delete" => Ok(Self::ProjectDelete), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ProjectsMutatePayloadVariant2Type { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for ProjectsMutatePayloadVariant2Type { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ProjectsMutateSuccess` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct ProjectsMutateSuccess { + #[serde( + rename = "archivedAt", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub archived_at: ::std::option::Option<::std::string::String>, + #[serde(rename = "autoPull", skip_serializing_if = "::std::option::Option::is_none")] + pub auto_pull: ::std::option::Option, + #[serde(rename = "createdAt")] + pub created_at: ::std::string::String, + #[serde( + rename = "defaultModelSelection", + deserialize_with = "::std::option::Option::deserialize" + )] + pub default_model_selection: ::std::option::Option, + #[serde( + rename = "defaultThreadEnvMode", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub default_thread_env_mode: ::std::option::Option< + ProjectsMutateSuccessDefaultThreadEnvMode, + >, + #[serde( + rename = "deletedAt", + deserialize_with = "::std::option::Option::deserialize" + )] + pub deleted_at: ::std::option::Option<::std::string::String>, + #[serde( + rename = "faviconPath", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub favicon_path: ::std::option::Option<::std::string::String>, + pub id: ::std::string::String, + #[serde(rename = "pinnedAt", skip_serializing_if = "::std::option::Option::is_none")] + pub pinned_at: ::std::option::Option<::std::string::String>, + #[serde( + rename = "projectIcon", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub project_icon: ::std::option::Option, + #[serde( + rename = "repositoryIdentity", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub repository_identity: ::std::option::Option, + pub scripts: DefaultProjectScripts, + pub title: ::std::string::String, + #[serde(rename = "updatedAt")] + pub updated_at: ::std::string::String, + #[serde(rename = "workspaceRoot")] + pub workspace_root: ::std::string::String, +} +///`ProjectsMutateSuccessDefaultThreadEnvMode` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ProjectsMutateSuccessDefaultThreadEnvMode { + #[serde(rename = "local")] + Local, + #[serde(rename = "worktree")] + Worktree, +} +impl ::std::fmt::Display for ProjectsMutateSuccessDefaultThreadEnvMode { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Local => f.write_str("local"), + Self::Worktree => f.write_str("worktree"), + } + } +} +impl ::std::str::FromStr for ProjectsMutateSuccessDefaultThreadEnvMode { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "local" => Ok(Self::Local), + "worktree" => Ok(Self::Worktree), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ProjectsMutateSuccessDefaultThreadEnvMode { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for ProjectsMutateSuccessDefaultThreadEnvMode { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} +///`ProjectsMutateSuccessProjectIcon` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +#[serde(untagged)] +pub enum ProjectsMutateSuccessProjectIcon { + Variant0(ProjectIcon), + Variant1 { + emoji: ::std::string::String, + kind: ProjectsMutateSuccessProjectIconVariant1Kind, + }, + Variant2(ProjectIcon2), + Variant3 { kind: ::std::string::String }, + Variant4, +} +impl ::std::convert::From for ProjectsMutateSuccessProjectIcon { + fn from(value: ProjectIcon) -> Self { + Self::Variant0(value) + } +} +impl ::std::convert::From for ProjectsMutateSuccessProjectIcon { + fn from(value: ProjectIcon2) -> Self { + Self::Variant2(value) + } +} +///`ProjectsMutateSuccessProjectIconVariant1Kind` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum ProjectsMutateSuccessProjectIconVariant1Kind { + #[serde(rename = "emoji")] + Emoji, +} +impl ::std::fmt::Display for ProjectsMutateSuccessProjectIconVariant1Kind { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::Emoji => f.write_str("emoji"), + } + } +} +impl ::std::str::FromStr for ProjectsMutateSuccessProjectIconVariant1Kind { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "emoji" => Ok(Self::Emoji), + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> for ProjectsMutateSuccessProjectIconVariant1Kind { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for ProjectsMutateSuccessProjectIconVariant1Kind { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} ///`PullRequests` #[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] #[serde(transparent)] diff --git a/apps/desktop-gpui/src/wire/mod.rs b/apps/desktop-gpui/src/wire/mod.rs index f1c9f2d703b5..6a918bb921fe 100644 --- a/apps/desktop-gpui/src/wire/mod.rs +++ b/apps/desktop-gpui/src/wire/mod.rs @@ -7,5 +7,7 @@ pub mod generated; pub use generated::{ OrchestrationSubscribeShellPayload as SubscribeShellPayload, OrchestrationSubscribeShellSuccess as ShellItem, ProjectsEntry as Project, + ProjectsMutatePayload as ProjectMutation, + ProjectsMutatePayloadVariant1Type as ProjectMutationType, ProjectsMutateSuccess, ThreadsEntry as Thread, }; diff --git a/apps/desktop-gpui/tests/wire_types.rs b/apps/desktop-gpui/tests/wire_types.rs index 88f87240ef78..658107540796 100644 --- a/apps/desktop-gpui/tests/wire_types.rs +++ b/apps/desktop-gpui/tests/wire_types.rs @@ -9,7 +9,7 @@ use std::path::PathBuf; /// RPC methods whose payload, success and error types are generated. Add a method here when the /// client starts calling it. -const METHODS: &[&str] = &["orchestration.subscribeShell"]; +const METHODS: &[&str] = &["orchestration.subscribeShell", "projects.mutate"]; /// Properties typify cannot represent, kept as `serde_json::Value`: (definition, property). /// Gaps are listed in the README. From 79d658784424393a1e245d252db54606212ca0ec Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 5 Nov 2026 02:23:19 -0500 Subject: [PATCH 09/82] feat(desktop-gpui): group projects by repository and badge other environments The picker listed every project on its own and never showed which other machines had a project, so it disagreed with the web. Projects now group by repository the way the web's default mode does, with the same label and icon rules, and pinning a group pins every member. Saved environments from the Electron app's connection catalog are read, so a project that also exists elsewhere gets the "Also on" badge. Reading Electron's catalog and DPoP key is a stopgap marked TODO(gpui-credentials) until this app has its own pairing. Signed-off-by: Yordis Prieto --- apps/desktop-gpui/Cargo.lock | 233 ++++++++ apps/desktop-gpui/Cargo.toml | 8 + apps/desktop-gpui/README.md | 9 + apps/desktop-gpui/src/assets.rs | 14 +- apps/desktop-gpui/src/auth.rs | 41 +- apps/desktop-gpui/src/catalog.rs | 635 ++++++++++++++++++++++ apps/desktop-gpui/src/config.rs | 33 ++ apps/desktop-gpui/src/connection.rs | 40 +- apps/desktop-gpui/src/electron_dpop.rs | 720 +++++++++++++++++++++++++ apps/desktop-gpui/src/environments.rs | 92 ++++ apps/desktop-gpui/src/main.rs | 11 + apps/desktop-gpui/src/projects.rs | 598 ++++++++++++++++++-- apps/desktop-gpui/src/server.rs | 56 +- apps/desktop-gpui/src/status.rs | 5 +- apps/desktop-gpui/src/ui.rs | 95 +++- 15 files changed, 2514 insertions(+), 76 deletions(-) create mode 100644 apps/desktop-gpui/src/catalog.rs create mode 100644 apps/desktop-gpui/src/electron_dpop.rs create mode 100644 apps/desktop-gpui/src/environments.rs diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index 1e3e164a6a4d..23c2051c8ed5 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -463,6 +463,12 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base62" version = "2.2.6" @@ -481,6 +487,12 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.10.0" @@ -1004,6 +1016,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1062,6 +1080,22 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11" +dependencies = [ + "cpubits", + "ctutils", + "getrandom 0.4.3", + "hybrid-array", + "num-traits", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1078,7 +1112,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -1098,6 +1134,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" dependencies = [ "cmov", + "subtle", ] [[package]] @@ -1112,6 +1149,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" +[[package]] +name = "der" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" +dependencies = [ + "const-oid", + "zeroize", +] + [[package]] name = "deranged" version = "0.5.9" @@ -1275,12 +1322,47 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" +dependencies = [ + "der", + "digest 0.11.3", + "elliptic-curve", + "rfc6979", + "signature", + "spki", + "zeroize", +] + [[package]] name = "either" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "elliptic-curve" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" +dependencies = [ + "base16ct", + "crypto-bigint", + "crypto-common 0.2.2", + "digest 0.11.3", + "ff", + "group", + "hybrid-array", + "pkcs8", + "rand_core 0.10.1", + "sec1", + "subtle", + "zeroize", +] + [[package]] name = "embed-resource" version = "3.0.12" @@ -1489,6 +1571,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" +dependencies = [ + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -2517,6 +2609,17 @@ dependencies = [ "smallvec", ] +[[package]] +name = "group" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" +dependencies = [ + "ff", + "rand_core 0.10.1", + "subtle", +] + [[package]] name = "h2" version = "0.4.20" @@ -2710,7 +2813,9 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ + "subtle", "typenum", + "zeroize", ] [[package]] @@ -4334,6 +4439,19 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "p256" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" +dependencies = [ + "ecdsa", + "elliptic-curve", + "primefield", + "primeorder", + "sha2 0.11.1", +] + [[package]] name = "parking" version = "2.2.1" @@ -4491,6 +4609,16 @@ dependencies = [ "futures-io", ] +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -4615,6 +4743,33 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "primefield" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" +dependencies = [ + "crypto-bigint", + "crypto-common 0.2.2", + "ff", + "rand_core 0.10.1", + "subtle", + "zeroize", +] + +[[package]] +name = "primeorder" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" +dependencies = [ + "elliptic-curve", + "once_cell", + "primefield", + "serdect", + "wnaf", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -5171,6 +5326,16 @@ dependencies = [ "zune-jpeg 0.5.15", ] +[[package]] +name = "rfc6979" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" +dependencies = [ + "crypto-bigint", + "hmac 0.13.0", +] + [[package]] name = "rgb" version = "0.8.53" @@ -5561,6 +5726,20 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "sec1" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" +dependencies = [ + "base16ct", + "ctutils", + "der", + "hybrid-array", + "subtle", + "zeroize", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -5747,6 +5926,16 @@ dependencies = [ "serde", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" version = "0.11.0" @@ -5811,6 +6000,16 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -5934,6 +6133,12 @@ dependencies = [ "serde_core", ] +[[package]] +name = "snap" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "199905e6153d6405f9728fe44daace35f8f837bbf830bb6e85fbd5828709a886" + [[package]] name = "socket2" version = "0.6.5" @@ -5971,6 +6176,16 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "spki" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -6177,7 +6392,9 @@ dependencies = [ name = "t3-desktop-gpui" version = "0.1.0" dependencies = [ + "aes", "async-channel", + "cbc", "futures-util", "getrandom 0.4.3", "gpui-component", @@ -6187,12 +6404,16 @@ dependencies = [ "hex", "hmac 0.13.0", "libc", + "p256", "prettyplease", "reqwest", "schemars 0.8.22", + "security-framework", "serde", "serde_json", + "sha1", "sha2 0.11.1", + "snap", "syn 3.0.9", "tokio", "tokio-tungstenite", @@ -7992,6 +8213,18 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +[[package]] +name = "wnaf" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" +dependencies = [ + "ff", + "group", + "hybrid-array", + "primefield", +] + [[package]] name = "writeable" version = "0.6.4" diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index 2d27e6b534c7..a28db8ac1b16 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -6,7 +6,9 @@ license = "MIT" publish = false [dependencies] +aes = "0.8.4" async-channel = "2.5.0" +cbc = { version = "0.1.2", features = ["alloc"] } futures-util = "0.3.34" getrandom = "0.4.3" gpui = { package = "gpui-pre", version = "=0.3.11" } @@ -16,10 +18,13 @@ gpui_platform = { package = "gpui-pre-platform", version = "=0.3.11", features = hex = "0.4.3" hmac = "0.13.0" libc = "0.2.191" +p256 = { version = "0.14", default-features = false, features = ["ecdsa", "pkcs8", "std"] } reqwest = { version = "0.13.5", default-features = false, features = ["form", "json", "rustls"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.154" +sha1 = "0.11.0" sha2 = "0.11.1" +snap = "1" tokio = { version = "1.54.1", features = [ "rt-multi-thread", "process", @@ -40,3 +45,6 @@ prettyplease = "0.3.0" schemars = "0.8" syn = "3.0.9" typify = "0.10.0-alpha.3" + +[target.'cfg(target_os = "macos")'.dependencies] +security-framework = "3.7.0" diff --git a/apps/desktop-gpui/README.md b/apps/desktop-gpui/README.md index 7f7fa8b2ea0a..f4647bc2a7dc 100644 --- a/apps/desktop-gpui/README.md +++ b/apps/desktop-gpui/README.md @@ -30,6 +30,15 @@ To see real data, attach to a server that is already running (for example your ` | `T3CODE_GPUI_SERVER_URL` | HTTP origin of the running server. Takes precedence. | | `T3CODE_GPUI_PAIRING_TOKEN` | The `Token:` value printed by `pair`. It works only once. | +When attached, the project picker also shows the Electron app's environment badge ("Also on ") for projects that exist on your other saved environments. It reads the Electron app's `connection-catalog.json`, decrypting it with the safeStorage key from the macOS Keychain (the OS may ask for access once). Only direct `http` environments with a saved bearer token are used; relay, T3 Connect, SSH and `https` entries are skipped with a log line. + +| Variable | Meaning | +| -------------------------------- | ------------------------------------------------------------------------------------ | +| `T3CODE_GPUI_CONNECTION_CATALOG` | Catalog path. Defaults to `connection-catalog.json` in `T3CODE_GPUI_HOME`. | +| `T3CODE_GPUI_ELECTRON_IDB` | Electron IndexedDB LevelDB directory (DPoP key). Defaults to the `t3code-dev` app's. | + +TODO(gpui-credentials): HACK. Saved T3 Connect tokens (`remoteDpopTokens`) are used by reading the Electron app's private IndexedDB for its non-extractable DPoP key (`src/electron_dpop.rs`). Replace with GPUI's own pairing + Keychain before shipping. + Other scripts: ```sh diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 9cca92b6de3f..dc388b45670d 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -2,7 +2,12 @@ use gpui::{AssetSource, Result, SharedString}; use std::borrow::Cow; // `Assets` carries only gpui-component's default icons; anything else from the catalog renders blank. -gpui_kit_assets::icon_assets!(ExtraIcons, [Paperclip, Pin, PinOff]); +gpui_kit_assets::icon_assets!( + ExtraIcons, + [ + Paperclip, Pin, PinOff, Server, Cloud, Terminal, Monitor, Laptop, HardDrive, Computer + ] +); pub struct AppAssets; @@ -33,6 +38,13 @@ mod tests { IconName::Pin, IconName::PinOff, IconName::Search, + IconName::Server, + IconName::Cloud, + IconName::Terminal, + IconName::Monitor, + IconName::Laptop, + IconName::HardDrive, + IconName::Computer, ] { let loaded = AppAssets.load(&icon.path()).ok().flatten(); assert!(loaded.is_some(), "{} is not embedded", icon.path()); diff --git a/apps/desktop-gpui/src/auth.rs b/apps/desktop-gpui/src/auth.rs index 20ad54b2b85a..fd5c0ed429df 100644 --- a/apps/desktop-gpui/src/auth.rs +++ b/apps/desktop-gpui/src/auth.rs @@ -2,10 +2,13 @@ //! Mirrors packages/shared/src/desktopBootstrapToken.ts and //! packages/client-runtime/src/authorization/remote.ts. +use crate::electron_dpop; use hmac::{Hmac, KeyInit, Mac}; +use p256::ecdsa::SigningKey; use serde::Deserialize; use sha2::Sha256; use std::fmt::Write; +use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; const TOKEN_WINDOW_MS: u64 = 12 * 60 * 60 * 1000; @@ -80,17 +83,25 @@ struct TicketResult { } /// What the app proves itself with: the secret it handed a server it spawned, -/// or a pairing token minted by `t3 pair` for a server it attaches to. +/// a pairing token minted by `t3 pair` for a server it attaches to, or a bearer +/// session another client already saved for it (the Electron app's catalog). pub enum Credential { DesktopSecret(String), Pairing(String), + Bearer(String), + // TODO(gpui-credentials): HACK. Reads Electron's private IndexedDB and non-extractable DPoP key. Replace with GPUI's own pairing + Keychain before shipping. + /// A DPoP-bound access token and the proof key it is bound to (`Authorization: DPoP`). + Dpop { + token: String, + key: Arc, + }, } impl Credential { fn subject_token(&self) -> String { match self { Self::DesktopSecret(secret) => bootstrap_token(secret, now_ms()), - Self::Pairing(token) => token.clone(), + Self::Pairing(token) | Self::Bearer(token) | Self::Dpop { token, .. } => token.clone(), } } } @@ -101,6 +112,10 @@ pub async fn exchange_bearer( http_base: &str, credential: &Credential, ) -> Result { + // A saved bearer or DPoP access token is already the session; there is nothing to exchange. + if let Credential::Bearer(token) | Credential::Dpop { token, .. } = credential { + return Ok(token.clone()); + } let credential = credential.subject_token(); let response = client .post(format!("{http_base}/oauth/token")) @@ -123,18 +138,28 @@ pub async fn exchange_bearer( } /// Mints an RPC WebSocket ticket. A server without audience routes answers 404 -/// for `/:use`, and its bare-path tickets are valid for RPC. +/// for `/:use`, and its bare-path tickets are valid for RPC. A DPoP credential signs a proof for +/// each path it tries (packages/client-runtime/src/authorization/remote.ts). pub async fn issue_ws_ticket( client: &reqwest::Client, http_base: &str, + credential: &Credential, bearer: &str, ) -> Result { let mint = |path: &'static str| async move { - client - .post(format!("{http_base}{path}")) - .bearer_auth(bearer) - .send() - .await + let url = format!("{http_base}{path}"); + let request = client.post(&url); + let request = match credential { + Credential::Dpop { key, .. } => { + let proof = electron_dpop::create_proof(key, "POST", &url, Some(bearer), now_ms()) + .map_err(AuthError::Failed)?; + request + .header("authorization", format!("DPoP {bearer}")) + .header("dpop", proof) + } + _ => request.bearer_auth(bearer), + }; + request.send().await.map_err(AuthError::from) }; let mut response = mint("/api/auth/websocket-ticket/rpc").await?; if response.status() == reqwest::StatusCode::NOT_FOUND { diff --git a/apps/desktop-gpui/src/catalog.rs b/apps/desktop-gpui/src/catalog.rs new file mode 100644 index 000000000000..c57805df4bde --- /dev/null +++ b/apps/desktop-gpui/src/catalog.rs @@ -0,0 +1,635 @@ +//! The Electron desktop app's saved connections: `connection-catalog.json` holds the catalog +//! encrypted with Electron safeStorage (DesktopConnectionCatalogStore.ts). The plaintext schema is +//! `ConnectionCatalogDocument` in packages/client-runtime/src/platform/storageDocument.ts. +//! Read-only: this module never writes the catalog and never logs a decrypted value. +//! +//! TODO(gpui-credentials): HACK. Reads Electron's private IndexedDB and non-extractable DPoP key. Replace with GPUI's own pairing + Keychain before shipping. +//! (`remoteDpopTokens` are only usable with Electron's DPoP key; see `electron_dpop`.) + +use crate::auth::{self, Credential}; +use crate::electron_dpop; +use aes::cipher::{BlockDecryptMut, KeyIvInit, block_padding::Pkcs7}; +use hmac::{Hmac, KeyInit, Mac}; +use p256::ecdsa::SigningKey; +use serde::Deserialize; +use sha1::Sha1; +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +type Aes128CbcDec = cbc::Decryptor; + +/// Chromium os_crypt_mac: key = PBKDF2-HMAC-SHA1(password, "saltysalt", 1003) of 16 bytes, +/// IV = 16 spaces, ciphertext prefixed with "v10". +const SAFE_STORAGE_PREFIX: &[u8] = b"v10"; +const SAFE_STORAGE_SALT: &[u8] = b"saltysalt"; +const SAFE_STORAGE_ITERATIONS: u32 = 1003; + +/// Keychain items Electron's safeStorage uses as (service, account). The service is named after +/// the app at launch: a dev launcher that runs the stock Electron binary stores `Electron`. +#[cfg(target_os = "macos")] +const KEYCHAIN_ITEMS: [(&str, &str); 2] = [ + ("Electron Safe Storage", "Electron Key"), + ("T3 Code (Dev) Safe Storage", "T3 Code (Dev) Key"), +]; + +pub const CATALOG_FILE: &str = "connection-catalog.json"; + +/// A directly reachable environment: a plain `http` base URL and the bearer token saved for it. +pub struct SavedEnvironment { + pub environment_id: String, + pub label: String, + pub http_base: String, + pub credential: Credential, +} + +/// What `load` found: environments to connect to, and the `(label, kind)` of those it cannot. +#[derive(Default)] +pub struct Catalog { + pub environments: Vec, + pub skipped: Vec<(String, &'static str)>, + /// One-line explanations for environments that are skipped for a specific reason. + pub notices: Vec, +} + +#[derive(Deserialize)] +struct Envelope { + version: u32, + #[serde(rename = "encryptedCatalog")] + encrypted_catalog: String, +} + +// Variant names are the wire `_tag` values. +#[allow(clippy::enum_variant_names)] +#[derive(Deserialize)] +#[serde(tag = "_tag")] +enum Target { + BearerConnectionTarget { + #[serde(rename = "environmentId")] + environment_id: String, + label: String, + #[serde(rename = "connectionId")] + connection_id: String, + }, + RelayConnectionTarget { + label: String, + }, + SshConnectionTarget { + label: String, + }, + #[serde(other)] + Unknown, +} + +#[derive(Deserialize)] +#[serde(tag = "_tag")] +enum Profile { + BearerConnectionProfile { + #[serde(rename = "connectionId")] + connection_id: String, + #[serde(rename = "httpBaseUrl")] + http_base_url: String, + authorization: Option, + }, + #[serde(other)] + Other, +} + +#[derive(Deserialize)] +#[serde(tag = "_tag")] +enum StoredToken { + BearerConnectionCredential { + token: String, + }, + #[serde(other)] + Other, +} + +#[derive(Deserialize)] +struct StoredCredential { + #[serde(rename = "connectionId")] + connection_id: String, + credential: StoredToken, +} + +/// `RemoteDpopAccessToken` (packages/client-runtime/src/authorization/tokenStore.ts). +#[derive(Deserialize)] +struct DpopToken { + #[serde(rename = "environmentId")] + environment_id: String, + endpoint: DpopEndpoint, + #[serde(rename = "accessToken")] + access_token: String, + #[serde(rename = "expiresAtEpochMs")] + expires_at_epoch_ms: f64, + #[serde(rename = "dpopThumbprint")] + dpop_thumbprint: String, +} + +#[derive(Deserialize)] +struct DpopEndpoint { + #[serde(rename = "httpBaseUrl")] + http_base_url: String, +} + +#[derive(Deserialize)] +struct Document { + #[serde(default)] + targets: Vec, + #[serde(default)] + profiles: Vec, + #[serde(default)] + credentials: Vec, + #[serde(rename = "remoteDpopTokens", default)] + remote_dpop_tokens: Vec, + #[serde(rename = "disabledEnvironmentIds", default)] + disabled_environment_ids: Vec, +} + +/// Why a bearer target cannot be dialed directly, as a short kind for the log. +fn bearer_kind(http_base: &str, authorization: Option<&str>) -> Option<&'static str> { + if authorization == Some("t3-connect") { + return Some("t3-connect"); + } + // tokio-tungstenite is built without TLS, so a wss socket cannot open. + (!http_base.starts_with("http://")).then_some("https") +} + +type KeyLoader<'a> = &'a mut dyn FnMut() -> Result, String>; + +/// Decodes the decrypted catalog JSON into the environments this client can reach. +/// `load_key` supplies Electron's DPoP key, and is called at most once, only when a saved +/// DPoP token is worth using. +pub fn parse(plaintext: &str, now_ms: u64, load_key: KeyLoader) -> Result { + // serde messages can quote the offending value, and this document holds tokens. + let document: Document = serde_json::from_str(plaintext).map_err(|error| { + format!( + "unreadable catalog document at {}:{}", + error.line(), + error.column() + ) + })?; + let profiles: HashMap<&str, (&str, Option<&str>)> = document + .profiles + .iter() + .filter_map(|profile| match profile { + Profile::BearerConnectionProfile { + connection_id, + http_base_url, + authorization, + } => Some(( + connection_id.as_str(), + (http_base_url.as_str(), authorization.as_deref()), + )), + Profile::Other => None, + }) + .collect(); + let tokens: HashMap<&str, &str> = document + .credentials + .iter() + .filter_map(|stored| match &stored.credential { + StoredToken::BearerConnectionCredential { token } => { + Some((stored.connection_id.as_str(), token.as_str())) + } + StoredToken::Other => None, + }) + .collect(); + let dpop_tokens: HashMap<&str, &DpopToken> = document + .remote_dpop_tokens + .iter() + .map(|token| (token.environment_id.as_str(), token)) + .collect(); + let mut key: Option, String>> = None; + let mut catalog = Catalog::default(); + for target in &document.targets { + match target { + Target::RelayConnectionTarget { label } => { + catalog.skipped.push((label.clone(), "relay")); + } + Target::SshConnectionTarget { label } => catalog.skipped.push((label.clone(), "ssh")), + Target::Unknown => {} + Target::BearerConnectionTarget { + environment_id, + label, + connection_id, + } => { + if document.disabled_environment_ids.contains(environment_id) { + continue; + } + let Some((http_base, authorization)) = profiles.get(connection_id.as_str()) else { + catalog.skipped.push((label.clone(), "no-profile")); + continue; + }; + let bearer = tokens.get(connection_id.as_str()); + // Electron signs in with the DPoP token for T3 Connect profiles, and for any + // profile that has no bearer of its own. + let dpop = dpop_tokens + .get(environment_id.as_str()) + .filter(|_| *authorization == Some("t3-connect") || bearer.is_none()); + if let Some(saved) = dpop { + match dpop_environment( + saved, + environment_id, + label, + http_base, + now_ms, + &mut key, + load_key, + ) { + Ok(environment) => catalog.environments.push(environment), + Err(DpopSkip::Notice(notice)) => catalog.notices.push(notice), + Err(DpopSkip::Kind(kind)) => catalog.skipped.push((label.clone(), kind)), + } + continue; + } + if let Some(kind) = bearer_kind(http_base, *authorization) { + catalog.skipped.push((label.clone(), kind)); + continue; + } + let Some(token) = bearer else { + catalog.skipped.push((label.clone(), "no-credential")); + continue; + }; + catalog.environments.push(SavedEnvironment { + environment_id: environment_id.clone(), + label: label.clone(), + http_base: http_base.trim_end_matches('/').to_owned(), + credential: Credential::Bearer((*token).to_owned()), + }); + } + } + } + // The same environment can be saved more than once (one target per pairing). Keep the first + // connectable entry, and do not report siblings of a connectable environment as skipped. + let mut seen = std::collections::HashSet::new(); + catalog + .environments + .retain(|saved| seen.insert(saved.environment_id.clone())); + catalog.skipped.retain(|(skipped, _)| { + !catalog + .environments + .iter() + .any(|saved| &saved.label == skipped) + }); + Ok(catalog) +} + +enum DpopSkip { + Notice(String), + Kind(&'static str), +} + +/// Maps a saved DPoP token to a connectable environment, or says why it cannot be used. The +/// direct profile address wins over the token's own endpoint; both must be plain `http`. +fn dpop_environment( + saved: &DpopToken, + environment_id: &str, + label: &str, + profile_base: &str, + now_ms: u64, + key: &mut Option, String>>, + load_key: KeyLoader, +) -> Result { + // The token is minted through the T3 Connect relay, which this client cannot reach, so an + // expired one cannot be renewed here. + if saved.expires_at_epoch_ms <= now_ms as f64 { + return Err(DpopSkip::Notice(format!( + "{label}: saved token expired, open the Electron app to refresh" + ))); + } + let http_base = [profile_base, saved.endpoint.http_base_url.as_str()] + .into_iter() + .find(|base| base.starts_with("http://")) + .ok_or(DpopSkip::Kind("https"))?; + let key = key.get_or_insert_with(load_key); + let key = key + .as_ref() + .map_err(|error| DpopSkip::Notice(format!("{label}: no Electron DPoP key ({error})")))?; + if electron_dpop::thumbprint(key) != saved.dpop_thumbprint { + return Err(DpopSkip::Notice(format!( + "{label}: Electron's DPoP key does not match the saved token" + ))); + } + Ok(SavedEnvironment { + environment_id: environment_id.to_owned(), + label: label.to_owned(), + http_base: http_base.trim_end_matches('/').to_owned(), + credential: Credential::Dpop { + token: saved.access_token.clone(), + key: key.clone(), + }, + }) +} + +/// PBKDF2-HMAC-SHA1 for one 16-byte block (SHA-1 yields 20 bytes, so a single block suffices). +fn pbkdf2_sha1_block(password: &[u8], salt: &[u8], iterations: u32) -> [u8; 16] { + let prf = |data: &[u8]| { + let mut mac = Hmac::::new_from_slice(password).expect("HMAC takes any key length"); + mac.update(data); + mac.finalize().into_bytes() + }; + let mut first = salt.to_vec(); + first.extend_from_slice(&1u32.to_be_bytes()); + let mut u = prf(&first); + let mut t = u; + for _ in 1..iterations { + u = prf(&u); + for (acc, byte) in t.iter_mut().zip(u.iter()) { + *acc ^= byte; + } + } + let mut key = [0u8; 16]; + key.copy_from_slice(&t[..16]); + key +} + +/// Decrypts Electron safeStorage ciphertext (`v10` + AES-128-CBC) with the Keychain password. +pub fn decrypt(ciphertext: &[u8], password: &[u8]) -> Result, String> { + let body = ciphertext + .strip_prefix(SAFE_STORAGE_PREFIX) + .ok_or("ciphertext is not v10 safeStorage data")?; + let key = pbkdf2_sha1_block(password, SAFE_STORAGE_SALT, SAFE_STORAGE_ITERATIONS); + Aes128CbcDec::new(&key.into(), &[b' '; 16].into()) + .decrypt_padded_vec_mut::(body) + .map_err(|_| "could not decrypt the catalog (wrong Keychain key?)".to_owned()) +} + +#[cfg(target_os = "macos")] +fn keychain_password() -> Result, String> { + use security_framework::passwords::get_generic_password; + let mut last = String::new(); + for (service, account) in KEYCHAIN_ITEMS { + match get_generic_password(service, account) { + Ok(password) => return Ok(password), + Err(error) => last = format!("{service}: {error}"), + } + } + Err(format!("could not read the safeStorage key ({last})")) +} + +#[cfg(not(target_os = "macos"))] +fn keychain_password() -> Result, String> { + Err("reading the safeStorage key is only supported on macOS".to_owned()) +} + +/// Reads, decrypts and decodes the catalog at `path`. Blocking: the Keychain may prompt. +pub fn load(path: &Path) -> Result { + let raw = std::fs::read_to_string(path).map_err(|error| error.to_string())?; + let envelope: Envelope = + serde_json::from_str(&raw).map_err(|error| format!("unreadable catalog file: {error}"))?; + if envelope.version != 1 { + return Err(format!("unsupported catalog version {}", envelope.version)); + } + let ciphertext = b64::decode(&envelope.encrypted_catalog)?; + let plaintext = decrypt(&ciphertext, &keychain_password()?)?; + let plaintext = String::from_utf8(plaintext).map_err(|_| "catalog is not UTF-8".to_owned())?; + // TODO(gpui-credentials): HACK. Reads Electron's private IndexedDB and non-extractable DPoP key. Replace with GPUI's own pairing + Keychain before shipping. + parse(&plaintext, auth::now_ms(), &mut || { + let dir = electron_dpop::default_idb_dir().ok_or("no IndexedDB location")?; + electron_dpop::load_key(&dir).map(Arc::new) + }) +} + +/// Standard base64 (RFC 4648, padded), enough for the envelope; no extra crate. +pub(crate) mod b64 { + pub fn decode(input: &str) -> Result, String> { + let mut out = Vec::with_capacity(input.len() * 3 / 4); + let (mut buffer, mut bits) = (0u32, 0u32); + for byte in input.trim().bytes() { + let value = match byte { + b'A'..=b'Z' => byte - b'A', + b'a'..=b'z' => byte - b'a' + 26, + b'0'..=b'9' => byte - b'0' + 52, + b'+' => 62, + b'/' => 63, + b'=' => break, + _ => return Err("catalog is not valid base64".to_owned()), + }; + buffer = (buffer << 6) | u32::from(value); + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((buffer >> bits) as u8); + buffer &= (1 << bits) - 1; + } + } + Ok(out) + } + + #[cfg(test)] + pub fn encode(input: &[u8]) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::new(); + for chunk in input.chunks(3) { + let n = chunk + .iter() + .enumerate() + .fold(0u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i)); + for i in 0..=chunk.len() { + out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char); + } + out.extend(std::iter::repeat_n('=', 3 - chunk.len())); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + use aes::cipher::BlockEncryptMut; + + type Aes128CbcEnc = cbc::Encryptor; + + /// Encrypts like safeStorage, with a synthetic password. + fn seal(plaintext: &[u8], password: &[u8]) -> Vec { + let key = pbkdf2_sha1_block(password, SAFE_STORAGE_SALT, SAFE_STORAGE_ITERATIONS); + let mut sealed = SAFE_STORAGE_PREFIX.to_vec(); + sealed.extend( + Aes128CbcEnc::new(&key.into(), &[b' '; 16].into()) + .encrypt_padded_vec_mut::(plaintext), + ); + sealed + } + + #[test] + fn pbkdf2_matches_the_rfc_6070_vector() { + // RFC 6070, P="password", S="salt", c=2, first 16 bytes of the derived key. + let key = pbkdf2_sha1_block(b"password", b"salt", 2); + assert_eq!(hex::encode(key), "ea6c014dc72d6f8ccd1ed92ace1d41f0"); + } + + #[test] + fn base64_round_trips() { + for sample in [&b""[..], b"a", b"ab", b"abc", b"abcd", &[0, 255, 128, 7, 9]] { + assert_eq!(b64::decode(&b64::encode(sample)).unwrap(), sample); + } + assert!(b64::decode("a*b").is_err()); + } + + #[test] + fn decrypts_what_safe_storage_would_produce() { + let sealed = seal(b"synthetic plaintext", b"synthetic-password"); + assert_eq!( + decrypt(&sealed, b"synthetic-password").unwrap(), + b"synthetic plaintext" + ); + assert!(decrypt(&sealed, b"another-password").is_err()); + assert!(decrypt(b"v11whatever", b"synthetic-password").is_err()); + assert!(decrypt(&sealed[..sealed.len() - 1], b"synthetic-password").is_err()); + } + + fn no_key() -> Result, String> { + Err("no key".to_owned()) + } + + const DOCUMENT: &str = r#"{ + "schemaVersion": 1, + "targets": [ + {"_tag":"BearerConnectionTarget","environmentId":"env-direct","label":"Direct","connectionId":"bearer:env-direct"}, + {"_tag":"BearerConnectionTarget","environmentId":"env-off","label":"Off","connectionId":"bearer:env-off"}, + {"_tag":"BearerConnectionTarget","environmentId":"env-connect","label":"Tunnel","connectionId":"bearer:env-connect"}, + {"_tag":"BearerConnectionTarget","environmentId":"env-tls","label":"Secure","connectionId":"bearer:env-tls"}, + {"_tag":"BearerConnectionTarget","environmentId":"env-bare","label":"Bare","connectionId":"bearer:env-bare"}, + {"_tag":"RelayConnectionTarget","environmentId":"env-relay","label":"Relayed"}, + {"_tag":"SshConnectionTarget","environmentId":"env-ssh","label":"Shell","connectionId":"ssh:env-ssh"}, + {"_tag":"FutureTarget","environmentId":"x","label":"Future"} + ], + "profiles": [ + {"_tag":"BearerConnectionProfile","connectionId":"bearer:env-direct","environmentId":"env-direct","label":"Direct","httpBaseUrl":"http://10.0.0.2:3773/","wsBaseUrl":"ws://10.0.0.2:3773/"}, + {"_tag":"BearerConnectionProfile","connectionId":"bearer:env-off","environmentId":"env-off","label":"Off","httpBaseUrl":"http://10.0.0.3:3773","wsBaseUrl":"ws://10.0.0.3:3773"}, + {"_tag":"BearerConnectionProfile","connectionId":"bearer:env-connect","environmentId":"env-connect","label":"Tunnel","httpBaseUrl":"http://tunnel.invalid","wsBaseUrl":"ws://tunnel.invalid","authorization":"t3-connect"}, + {"_tag":"BearerConnectionProfile","connectionId":"bearer:env-tls","environmentId":"env-tls","label":"Secure","httpBaseUrl":"https://host.invalid","wsBaseUrl":"wss://host.invalid"}, + {"_tag":"BearerConnectionProfile","connectionId":"bearer:env-bare","environmentId":"env-bare","label":"Bare","httpBaseUrl":"http://10.0.0.4:3773","wsBaseUrl":"ws://10.0.0.4:3773"}, + {"_tag":"SshConnectionProfile","connectionId":"ssh:env-ssh","environmentId":"env-ssh","label":"Shell","target":{}} + ], + "credentials": [ + {"connectionId":"bearer:env-direct","credential":{"_tag":"BearerConnectionCredential","token":"synthetic-token"}}, + {"connectionId":"bearer:env-off","credential":{"_tag":"BearerConnectionCredential","token":"synthetic-token-2"}} + ], + "remoteDpopTokens": [], + "disabledEnvironmentIds": ["env-off"] + }"#; + + #[test] + fn parse_keeps_direct_bearer_environments_and_names_the_rest() { + let catalog = parse(DOCUMENT, 0, &mut no_key).unwrap(); + assert_eq!(catalog.environments.len(), 1); + let direct = &catalog.environments[0]; + assert_eq!(direct.environment_id, "env-direct"); + assert_eq!(direct.label, "Direct"); + assert_eq!(direct.http_base, "http://10.0.0.2:3773"); + assert!(matches!(&direct.credential, Credential::Bearer(t) if t == "synthetic-token")); + assert_eq!( + catalog.skipped, + [ + ("Tunnel".to_owned(), "t3-connect"), + ("Secure".to_owned(), "https"), + ("Bare".to_owned(), "no-credential"), + ("Relayed".to_owned(), "relay"), + ("Shell".to_owned(), "ssh"), + ] + ); + assert!(parse("{", 0, &mut no_key).is_err()); + } + + fn dpop_document(thumbprint: &str, expires_at: u64, authorization: &str, base: &str) -> String { + format!( + r#"{{"targets":[{{"_tag":"BearerConnectionTarget","environmentId":"env-a","label":"Devbox","connectionId":"bearer:env-a"}}], + "profiles":[{{"_tag":"BearerConnectionProfile","connectionId":"bearer:env-a","httpBaseUrl":"{base}"{authorization}}}], + "credentials":[], + "remoteDpopTokens":[{{"environmentId":"env-a","label":"Devbox","accessToken":"synthetic-access", + "endpoint":{{"httpBaseUrl":"https://relay.invalid","wsBaseUrl":"wss://relay.invalid","providerKind":"t3_relay"}}, + "expiresAtEpochMs":{expires_at},"dpopThumbprint":"{thumbprint}"}}]}}"# + ) + } + + #[test] + fn dpop_tokens_become_credentials_when_fresh_and_bound_to_the_electron_key() { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).unwrap(); + let key = Arc::new(SigningKey::from_slice(&seed).unwrap()); + let bound = electron_dpop::thumbprint(&key); + let mut loads = 0; + let mut load = || { + loads += 1; + Ok(key.clone()) + }; + + let fresh = dpop_document(&bound, 2_000, "", "http://10.0.0.5:3773/"); + let catalog = parse(&fresh, 1_000, &mut load).unwrap(); + assert_eq!(catalog.environments.len(), 1); + let saved = &catalog.environments[0]; + assert_eq!(saved.http_base, "http://10.0.0.5:3773"); + assert!(matches!( + &saved.credential, + Credential::Dpop { token, .. } if token == "synthetic-access" + )); + + // The token's own relay endpoint is https, so a t3-connect profile still dials its direct address. + let connect = dpop_document( + &bound, + 2_000, + r#","authorization":"t3-connect""#, + "http://10.0.0.5:3773", + ); + assert_eq!( + parse(&connect, 1_000, &mut load) + .unwrap() + .environments + .len(), + 1 + ); + + let expired = parse(&fresh, 2_000, &mut load).unwrap(); + assert!(expired.environments.is_empty()); + assert_eq!( + expired.notices, + ["Devbox: saved token expired, open the Electron app to refresh"] + ); + + let other = dpop_document("someone-else", 2_000, "", "http://10.0.0.5:3773"); + let mismatched = parse(&other, 1_000, &mut load).unwrap(); + assert!(mismatched.environments.is_empty()); + assert!(mismatched.notices[0].contains("does not match")); + + let tls = dpop_document(&bound, 2_000, "", "https://host.invalid"); + assert_eq!( + parse(&tls, 1_000, &mut load).unwrap().skipped, + [("Devbox".to_owned(), "https")] + ); + + let keyless = parse(&fresh, 1_000, &mut no_key).unwrap(); + assert!(keyless.environments.is_empty()); + assert!(keyless.notices[0].contains("no Electron DPoP key")); + // Expired tokens never touch the IndexedDB. + assert_eq!(loads, 3); + } + + #[test] + fn load_reads_the_envelope_and_rejects_other_versions() { + let dir = std::env::temp_dir().join(format!("t3-gpui-catalog-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join(CATALOG_FILE); + std::fs::write(&path, r#"{"version":2,"encryptedCatalog":""}"#).unwrap(); + assert!(load(&path).err().unwrap().contains("version")); + std::fs::write(&path, "nope").unwrap(); + assert!(load(&path).is_err()); + std::fs::remove_dir_all(&dir).unwrap(); + assert!(load(&path).is_err()); + } + + #[test] + fn duplicate_saves_of_one_environment_collapse_to_the_connectable_one() { + let document = r#"{"targets":[ + {"_tag":"BearerConnectionTarget","environmentId":"e","label":"Box","connectionId":"c1"}, + {"_tag":"BearerConnectionTarget","environmentId":"e","label":"Box","connectionId":"c2"}], + "profiles":[ + {"_tag":"BearerConnectionProfile","connectionId":"c1","httpBaseUrl":"http://10.0.0.9:1"}, + {"_tag":"BearerConnectionProfile","connectionId":"c2","httpBaseUrl":"http://10.0.0.9:1"}], + "credentials":[{"connectionId":"c2","credential":{"_tag":"BearerConnectionCredential","token":"t"}}]}"#; + let catalog = parse(document, 0, &mut no_key).unwrap(); + assert_eq!(catalog.environments.len(), 1); + assert!(catalog.skipped.is_empty()); + } +} diff --git a/apps/desktop-gpui/src/config.rs b/apps/desktop-gpui/src/config.rs index d9bf28e9233b..6aa1cfaf930e 100644 --- a/apps/desktop-gpui/src/config.rs +++ b/apps/desktop-gpui/src/config.rs @@ -6,6 +6,22 @@ pub const ENV_COMMAND: &str = "T3CODE_GPUI_SERVER_COMMAND"; pub const ENV_ENTRY: &str = "T3CODE_GPUI_SERVER_ENTRY"; pub const ENV_ATTACH_URL: &str = "T3CODE_GPUI_SERVER_URL"; pub const ENV_PAIRING_TOKEN: &str = "T3CODE_GPUI_PAIRING_TOKEN"; +pub const ENV_CONNECTION_CATALOG: &str = "T3CODE_GPUI_CONNECTION_CATALOG"; + +/// The Electron app's saved connections: the override, else the catalog inside the T3 home. +pub fn connection_catalog_path() -> Option { + catalog_path_from(|name| std::env::var(name).ok()) +} + +fn catalog_path_from(get: impl Fn(&str) -> Option) -> Option { + let non_empty = |name| get(name).filter(|value| !value.trim().is_empty()); + non_empty(ENV_CONNECTION_CATALOG) + .map(std::path::PathBuf::from) + .or_else(|| { + non_empty(ENV_HOME) + .map(|home| std::path::Path::new(&home).join(crate::catalog::CATALOG_FILE)) + }) +} /// A server someone else runs (a dev server, `npx t3`, a remote host), reached /// with a pairing token instead of being spawned. @@ -125,4 +141,21 @@ mod tests { None ); } + + #[test] + fn catalog_path_prefers_the_override_then_the_t3_home() { + let path = |pairs| catalog_path_from(lookup(pairs)); + assert_eq!( + path(&[ + (ENV_CONNECTION_CATALOG, "/c/catalog.json"), + (ENV_HOME, "/h") + ]), + Some("/c/catalog.json".into()) + ); + assert_eq!( + path(&[(ENV_HOME, "/h")]), + Some("/h/connection-catalog.json".into()) + ); + assert_eq!(path(&[]), None); + } } diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index e51b83dfa4b8..39ca9e93cb2d 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -1,6 +1,8 @@ //! The authenticated RPC WebSocket: connect, keep alive, reconnect. use crate::auth::{self, AuthError, Credential}; +use crate::electron_dpop; +use crate::projects::MachineKind; use crate::rpc::{Event, PING_FRAME, ProjectsMutate, Session, SubscribeShell}; use crate::shell::ShellState; use crate::status::{Status, StatusSender, publish}; @@ -19,7 +21,10 @@ const ORCHESTRATION_PROTOCOL_VERSION: &str = "2"; /// What the connection needs to know about the server it dialed. pub struct Environment { + pub id: String, pub label: String, + /// The machine icon the server's descriptor reports (`platform.machine`). + pub machine: MachineKind, /// The server keeps `pinned` from `project.update` (`capabilities.projectOrganization`). pub project_organization: bool, } @@ -112,6 +117,24 @@ fn socket_url(http_base: &str, ticket: &str) -> Result { .map_err(|error| error.to_string()) } +/// The HTTP request a WebSocket upgrade is, for the proof's `htu`: scheme swapped, no query. +fn upgrade_htu(url: &reqwest::Url) -> String { + let scheme = if url.scheme() == "wss" { + "https" + } else { + "http" + }; + let port = url + .port() + .map(|port| format!(":{port}")) + .unwrap_or_default(); + format!( + "{scheme}://{}{port}{}", + url.host_str().unwrap_or_default(), + url.path() + ) +} + /// Connects and stays connected until cancelled, reporting through `status`. pub async fn run( http_base: &str, @@ -164,7 +187,7 @@ async fn connect( token } }; - let ticket = match auth::issue_ws_ticket(client, http_base, &token).await { + let ticket = match auth::issue_ws_ticket(client, http_base, credential, &token).await { Ok(ticket) => ticket, Err(error) => { if matches!(error, AuthError::Rejected(_)) { @@ -173,7 +196,20 @@ async fn connect( return Err(error.to_string()); } }; - let url = socket_url(http_base, &ticket)?; + let mut url = socket_url(http_base, &ticket)?; + // TODO(gpui-credentials): HACK. Reads Electron's private IndexedDB and non-extractable DPoP key. Replace with GPUI's own pairing + Keychain before shipping. + if let Credential::Dpop { key, .. } = credential { + // A DPoP ticket is key-bound: the upgrade carries a proof for the socket URL in `dpop`, + // with the ticket as the token it hashes (remote.ts resolveRemoteDpopWebSocketConnectionUrl). + let proof = electron_dpop::create_proof( + key, + "GET", + &upgrade_htu(&url), + Some(&ticket), + auth::now_ms(), + )?; + url.query_pairs_mut().append_pair("dpop", &proof); + } let (socket, _) = tokio_tungstenite::connect_async(url.as_str()) .await .map_err(|error| error.to_string())?; diff --git a/apps/desktop-gpui/src/electron_dpop.rs b/apps/desktop-gpui/src/electron_dpop.rs new file mode 100644 index 000000000000..b83c293a3b95 --- /dev/null +++ b/apps/desktop-gpui/src/electron_dpop.rs @@ -0,0 +1,720 @@ +// TODO(gpui-credentials): HACK. Reads Electron's private IndexedDB and non-extractable DPoP key. Replace with GPUI's own pairing + Keychain before shipping. +// +//! The Electron app's DPoP proof key, and RFC 9449 proofs signed with it. +//! +//! apps/web/src/cloud/dpop.ts keeps the key as `{privateKey: CryptoKey, publicJwk, thumbprint}` in +//! IndexedDB (`t3code:cloud-auth` / `keys` / `relay-dpop-proof-key`). Chromium stores that in a +//! LevelDB directory as a Blink structured clone, where an EC CryptoKey is the `K` tag followed by +//! its algorithm, curve, usages and PKCS8 bytes. This module copies the directory to a temp dir, +//! scans the table and log files for the record (no LevelDB crate: the `idb_cmp1` comparator is +//! irrelevant to a scan), and decodes the key. Claims mirror +//! packages/client-runtime/src/relay/dpopProof.ts and packages/shared/src/dpopCommon.ts. +//! Nothing here logs or returns key material beyond the `SigningKey` itself. + +use p256::ecdsa::{Signature, SigningKey, signature::Signer}; +use p256::pkcs8::DecodePrivateKey; +use serde_json::json; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +/// Where Electron's dev app keeps IndexedDB for the `t3code-dev` app. +pub const ENV_IDB_DIR: &str = "T3CODE_GPUI_ELECTRON_IDB"; +const KEY_ID: &str = "relay-dpop-proof-key"; + +pub fn default_idb_dir() -> Option { + if let Some(dir) = std::env::var_os(ENV_IDB_DIR).filter(|dir| !dir.is_empty()) { + return Some(dir.into()); + } + let home = std::env::var_os("HOME")?; + Some( + Path::new(&home) + .join("Library/Application Support/t3code-dev/IndexedDB") + .join("t3code-dev_app_0.indexeddb.leveldb"), + ) +} + +// --------------------------------------------------------------------------------------------- +// base64url (RFC 4648 section 5, no padding) + +pub fn b64url_encode(input: &[u8]) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::with_capacity(input.len().div_ceil(3) * 4); + for chunk in input.chunks(3) { + let n = chunk + .iter() + .enumerate() + .fold(0u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i)); + for i in 0..=chunk.len() { + out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char); + } + } + out +} + +// --------------------------------------------------------------------------------------------- +// Proof signing + +/// The key's public JWK members, in RFC 7638 order. +fn public_jwk(key: &SigningKey) -> serde_json::Value { + let point = key.verifying_key().to_sec1_point(false); + let (x, y) = ( + point.x().expect("uncompressed point has x"), + point.y().expect("uncompressed point has y"), + ); + json!({"crv": "P-256", "kty": "EC", "x": b64url_encode(x), "y": b64url_encode(y)}) +} + +/// RFC 7638 thumbprint, as stored in `RemoteDpopAccessToken.dpopThumbprint`. +pub fn thumbprint(key: &SigningKey) -> String { + // serde_json's map is sorted, which is the lexicographic order the RFC asks for. + b64url_encode(&Sha256::digest(public_jwk(key).to_string().as_bytes())) +} + +/// `normalizeDpopHtu`: the URL without query or fragment. +pub fn normalize_htu(url: &str) -> Result { + let mut parsed = reqwest::Url::parse(url).map_err(|_| "DPoP URL is invalid.".to_owned())?; + parsed.set_fragment(None); + parsed.set_query(None); + Ok(parsed.to_string()) +} + +fn random_uuid_v4() -> Result { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).map_err(|error| format!("could not read random bytes: {error}"))?; + bytes[6] = bytes[6] & 0x0f | 0x40; + bytes[8] = bytes[8] & 0x3f | 0x80; + let hex = hex::encode(bytes); + Ok(format!( + "{}-{}-{}-{}-{}", + &hex[..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..] + )) +} + +/// `createProof`: an ES256 JWS with `typ: dpop+jwt` and the public JWK, bound to the request +/// method and URL, and to `access_token` through `ath` when there is one. +pub fn create_proof( + key: &SigningKey, + method: &str, + url: &str, + access_token: Option<&str>, + now_ms: u64, +) -> Result { + let header = json!({"typ": "dpop+jwt", "alg": "ES256", "jwk": public_jwk(key)}); + let mut claims = json!({ + "htm": method.to_uppercase(), + "htu": normalize_htu(url)?, + "jti": random_uuid_v4()?, + "iat": now_ms / 1_000, + }); + if let Some(token) = access_token.filter(|token| !token.is_empty()) { + claims["ath"] = json!(b64url_encode(&Sha256::digest(token.as_bytes()))); + } + let signing_input = format!( + "{}.{}", + b64url_encode(header.to_string().as_bytes()), + b64url_encode(claims.to_string().as_bytes()) + ); + let signature: Signature = key.sign(signing_input.as_bytes()); + Ok(format!( + "{signing_input}.{}", + b64url_encode(&signature.to_bytes()) + )) +} + +// --------------------------------------------------------------------------------------------- +// Blink CryptoKey + +/// V8 `ValueSerializer` varint (base-128, little endian). +fn read_varint(bytes: &[u8], at: &mut usize) -> Option { + let (mut value, mut shift) = (0u64, 0u32); + loop { + let byte = *bytes.get(*at)?; + *at += 1; + if shift >= 64 { + return None; + } + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + return Some(value); + } + shift += 7; + } +} + +const CRYPTO_KEY_TAG: u8 = b'K'; +const EC_KEY_TAG: u8 = 5; +const PRIVATE_KEY_TYPE: u64 = 2; +const P256_CURVE_TAG: u64 = 1; + +/// Decodes the EC private `CryptoKey` that starts at `bytes[0]` (`V8ScriptValueSerializerForModules` +/// layout: `K`, EC sub-tag, algorithm, curve, key type, usages, length, PKCS8 bytes). +fn decode_crypto_key_at(bytes: &[u8]) -> Option { + let mut at = 0; + if bytes.get(at)? != &CRYPTO_KEY_TAG || bytes.get(at + 1)? != &EC_KEY_TAG { + return None; + } + at += 2; + let _algorithm = read_varint(bytes, &mut at)?; + if read_varint(bytes, &mut at)? != P256_CURVE_TAG { + return None; + } + if read_varint(bytes, &mut at)? != PRIVATE_KEY_TYPE { + return None; + } + let _usages = read_varint(bytes, &mut at)?; + let length = usize::try_from(read_varint(bytes, &mut at)?).ok()?; + let pkcs8 = bytes.get(at..at.checked_add(length)?)?; + SigningKey::from_pkcs8_der(pkcs8).ok() +} + +/// Finds the stored private CryptoKey anywhere in an IndexedDB record value. The surrounding +/// object (`publicJwk`, `thumbprint`) is skipped rather than parsed: the key's tag bytes are +/// validated by their PKCS8 payload, so a false hit cannot decode. +pub fn decode_crypto_key(value: &[u8]) -> Option { + (0..value.len()) + .filter(|&at| value[at] == CRYPTO_KEY_TAG) + .find_map(|at| decode_crypto_key_at(&value[at..])) +} + +/// A record value may be wrapped in Chromium's snappy envelope (tag 0x11 after the version). +fn key_from_record(value: &[u8]) -> Option { + decode_crypto_key(value).or_else(|| { + (0..value.len().min(16)) + .filter(|&at| value[at] == 0x11) + .find_map(|at| { + let inflated = snap::raw::Decoder::new() + .decompress_vec(&value[at + 1..]) + .ok()?; + decode_crypto_key(&inflated) + }) + }) +} + +// --------------------------------------------------------------------------------------------- +// LevelDB scan + +struct Record { + sequence: u64, + deleted: bool, + value: Vec, +} + +/// IndexedDB's key for `relay-dpop-proof-key` in an object store: a prefix (database, store and +/// index ids; index 1 is the object store data) and the string key (`0x01`, length, UTF-16BE). +fn is_key_record(user_key: &[u8]) -> bool { + let Some(&flags) = user_key.first() else { + return false; + }; + let db_bytes = usize::from(flags >> 5 & 7) + 1; + let store_bytes = usize::from(flags >> 2 & 7) + 1; + let index_bytes = usize::from(flags & 3) + 1; + let prefix = 1 + db_bytes + store_bytes + index_bytes; + let Some(index) = user_key.get(prefix - index_bytes..prefix) else { + return false; + }; + let index_id = index + .iter() + .enumerate() + .fold(0u64, |id, (i, byte)| id | u64::from(*byte) << (8 * i)); + let mut expected = vec![0x01, KEY_ID.len() as u8]; + expected.extend(KEY_ID.encode_utf16().flat_map(u16::to_be_bytes)); + index_id == 1 && user_key.get(prefix..) == Some(expected.as_slice()) +} + +fn read_u32_le(bytes: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?)) +} + +fn read_slice<'a>(bytes: &'a [u8], at: &mut usize) -> Option<&'a [u8]> { + let length = usize::try_from(read_varint(bytes, at)?).ok()?; + let slice = bytes.get(*at..at.checked_add(length)?)?; + *at += length; + Some(slice) +} + +fn keep_newest(found: &mut HashMap, Record>, user_key: &[u8], record: Record) { + if !is_key_record(user_key) { + return; + } + match found.get(user_key) { + Some(existing) if existing.sequence >= record.sequence => {} + _ => { + found.insert(user_key.to_vec(), record); + } + } +} + +/// `.log` files: 32 KiB blocks of `crc(4) length(2) type(1)` fragments that reassemble into +/// WriteBatches (`sequence(8) count(4)` then `type key [value]` entries). +fn scan_log(data: &[u8], found: &mut HashMap, Record>) { + const BLOCK: usize = 32 * 1024; + let mut batch = Vec::new(); + let mut batches = Vec::new(); + for block in data.chunks(BLOCK) { + let mut at = 0; + while at + 7 <= block.len() { + let length = usize::from(u16::from_le_bytes([block[at + 4], block[at + 5]])); + let kind = block[at + 6]; + let Some(payload) = block.get(at + 7..at + 7 + length) else { + break; + }; + at += 7 + length; + match kind { + 1 => batches.push(payload.to_vec()), + 2 => batch = payload.to_vec(), + 3 => batch.extend_from_slice(payload), + 4 => { + batch.extend_from_slice(payload); + batches.push(std::mem::take(&mut batch)); + } + _ => {} + } + } + } + for batch in batches { + let Some(sequence) = batch + .get(..8) + .map(|bytes| u64::from_le_bytes(bytes.try_into().expect("8 bytes"))) + else { + continue; + }; + let Some(count) = read_u32_le(&batch, 8) else { + continue; + }; + let mut at = 12; + for index in 0..u64::from(count) { + let Some(&kind) = batch.get(at) else { break }; + at += 1; + let Some(key) = read_slice(&batch, &mut at) else { + break; + }; + let value = if kind == 1 { + match read_slice(&batch, &mut at) { + Some(value) => value, + None => break, + } + } else { + &[] + }; + keep_newest( + found, + key, + Record { + sequence: sequence + index, + deleted: kind == 0, + value: value.to_vec(), + }, + ); + } + } +} + +const TABLE_MAGIC: [u8; 8] = [0x57, 0xfb, 0x80, 0x8b, 0x24, 0x75, 0x47, 0xdb]; + +fn read_handle(bytes: &[u8], at: &mut usize) -> Option<(usize, usize)> { + let offset = usize::try_from(read_varint(bytes, at)?).ok()?; + let size = usize::try_from(read_varint(bytes, at)?).ok()?; + Some((offset, size)) +} + +/// A table block's contents, snappy-inflated when its trailer says so. +fn read_block(data: &[u8], (offset, size): (usize, usize)) -> Option> { + let contents = data.get(offset..offset.checked_add(size)?)?; + match data.get(offset + size)? { + 0 => Some(contents.to_vec()), + 1 => snap::raw::Decoder::new().decompress_vec(contents).ok(), + _ => None, + } +} + +/// The `(key, value)` entries of a block (prefix-compressed, restart array at the end). +fn block_entries(block: &[u8]) -> Vec<(Vec, &[u8])> { + let mut entries = Vec::new(); + let Some(restarts) = read_u32_le(block, block.len().saturating_sub(4)) else { + return entries; + }; + let Some(end) = block.len().checked_sub(4 + 4 * restarts as usize) else { + return entries; + }; + let (mut at, mut key) = (0, Vec::new()); + while at < end { + let (Some(shared), Some(unshared), Some(value_length)) = ( + read_varint(block, &mut at), + read_varint(block, &mut at), + read_varint(block, &mut at), + ) else { + break; + }; + let (shared, unshared, value_length) = + (shared as usize, unshared as usize, value_length as usize); + let (Some(delta), Some(value)) = ( + block.get(at..at.saturating_add(unshared)), + block.get(at + unshared..(at + unshared).saturating_add(value_length)), + ) else { + break; + }; + if shared > key.len() { + break; + } + key.truncate(shared); + key.extend_from_slice(delta); + at += unshared + value_length; + entries.push((key.clone(), value)); + } + entries +} + +/// `.ldb` files: footer, index block of data block handles, data blocks of internal keys +/// (`user key` + `sequence << 8 | type`). +fn scan_table(data: &[u8], found: &mut HashMap, Record>) { + let Some(footer) = data.len().checked_sub(48).map(|start| &data[start..]) else { + return; + }; + if footer[40..] != TABLE_MAGIC { + return; + } + let mut at = 0; + if read_handle(footer, &mut at).is_none() { + return; + } + let Some(index_handle) = read_handle(footer, &mut at) else { + return; + }; + let Some(index) = read_block(data, index_handle) else { + return; + }; + for (_, handle) in block_entries(&index) { + let mut at = 0; + let Some(block) = read_handle(handle, &mut at).and_then(|h| read_block(data, h)) else { + continue; + }; + for (internal_key, value) in block_entries(&block) { + let Some(split) = internal_key.len().checked_sub(8) else { + continue; + }; + let trailer = u64::from_le_bytes(internal_key[split..].try_into().expect("8 bytes")); + keep_newest( + found, + &internal_key[..split], + Record { + sequence: trailer >> 8, + deleted: trailer & 0xff == 0, + value: value.to_vec(), + }, + ); + } + } +} + +/// Removes the temp copy on every exit path. +struct TempCopy(PathBuf); + +impl Drop for TempCopy { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn scan_directory(dir: &Path) -> Result, String> { + let mut found = HashMap::new(); + let entries = std::fs::read_dir(dir).map_err(|error| error.to_string())?; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(data) = std::fs::read(&path) else { + continue; + }; + match path.extension().and_then(|extension| extension.to_str()) { + Some("ldb") | Some("sst") => scan_table(&data, &mut found), + Some("log") => scan_log(&data, &mut found), + _ => {} + } + } + Ok(found.into_values().filter(|r| !r.deleted).collect()) +} + +/// Reads the Electron app's DPoP proof key from its IndexedDB directory. Only a copy of the +/// `.ldb` and `.log` files is read, and the copy is deleted before this returns. +pub fn load_key(idb_dir: &Path) -> Result { + let mut suffix = [0u8; 8]; + getrandom::fill(&mut suffix).map_err(|error| error.to_string())?; + let copy = TempCopy(std::env::temp_dir().join(format!("t3-gpui-idb-{}", hex::encode(suffix)))); + std::fs::create_dir(©.0).map_err(|error| error.to_string())?; + let entries = std::fs::read_dir(idb_dir) + .map_err(|error| format!("could not read the Electron IndexedDB: {error}"))?; + for entry in entries.flatten() { + let path = entry.path(); + if matches!( + path.extension().and_then(|extension| extension.to_str()), + Some("ldb") | Some("sst") | Some("log") + ) && let Some(name) = path.file_name() + { + std::fs::copy(&path, copy.0.join(name)).map_err(|error| error.to_string())?; + } + } + let mut records = scan_directory(©.0)?; + records.sort_by_key(|record| std::cmp::Reverse(record.sequence)); + records + .iter() + .find_map(|record| key_from_record(&record.value)) + .ok_or_else(|| "no DPoP proof key in the Electron IndexedDB".to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use p256::pkcs8::EncodePrivateKey; + + fn varint(mut value: u64) -> Vec { + let mut out = Vec::new(); + loop { + let byte = (value & 0x7f) as u8; + value >>= 7; + if value == 0 { + out.push(byte); + return out; + } + out.push(byte | 0x80); + } + } + + fn fresh_key() -> SigningKey { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).unwrap(); + SigningKey::from_slice(&seed).unwrap() + } + + /// What Blink writes for an extractable-false ECDSA P-256 private key with the `sign` usage. + fn serialized_crypto_key(key: &SigningKey) -> Vec { + let pkcs8 = key.to_pkcs8_der().unwrap(); + let mut out = vec![CRYPTO_KEY_TAG, EC_KEY_TAG]; + out.extend(varint(13)); // ECDSA + out.extend(varint(P256_CURVE_TAG)); + out.extend(varint(PRIVATE_KEY_TYPE)); + out.extend(varint(1 << 3)); // sign + out.extend(varint(pkcs8.as_bytes().len() as u64)); + out.extend_from_slice(pkcs8.as_bytes()); + out + } + + /// An IndexedDB value: version, wrapper bytes, then an object whose first property is the key. + fn record_value(key: &SigningKey) -> Vec { + let mut value = vec![0x00, 0xff, 0x11, 0xff, 0x0f, b'o', b'"', 10]; + value.extend_from_slice(b"privateKey"); + value.extend(serialized_crypto_key(key)); + value.extend_from_slice(b"\"\x09publicJwk{"); + value + } + + #[test] + fn decodes_a_blink_serialized_key_inside_a_record() { + let key = fresh_key(); + let decoded = decode_crypto_key(&record_value(&key)).expect("key"); + assert_eq!(thumbprint(&decoded), thumbprint(&key)); + assert!(decode_crypto_key(b"K\x05\x0d\x01\x02").is_none()); + assert!(decode_crypto_key(&[]).is_none()); + } + + #[test] + fn rejects_public_and_other_curve_keys() { + let mut other_curve = serialized_crypto_key(&fresh_key()); + other_curve[3] = 2; // P-384 + assert!(decode_crypto_key(&other_curve).is_none()); + let mut public = serialized_crypto_key(&fresh_key()); + public[4] = 1; + assert!(decode_crypto_key(&public).is_none()); + } + + #[test] + fn decodes_a_snappy_wrapped_record() { + let key = fresh_key(); + let mut value = vec![0x00, 0x11]; + value.extend( + snap::raw::Encoder::new() + .compress_vec(&record_value(&key)) + .unwrap(), + ); + // Without the envelope the compressed bytes hold no key, so the wrapper path is the one used. + let decoded = key_from_record(&value).expect("key"); + assert_eq!(thumbprint(&decoded), thumbprint(&key)); + } + + #[test] + fn recognises_the_idb_object_store_key() { + let mut key = vec![0x00, 0x01, 0x02, 0x01]; // 1-byte ids, db 1, store 2, index 1 + key.extend([0x01, KEY_ID.len() as u8]); + key.extend(KEY_ID.encode_utf16().flat_map(u16::to_be_bytes)); + assert!(is_key_record(&key)); + let mut other_index = key.clone(); + other_index[3] = 2; + assert!(!is_key_record(&other_index)); + assert!(!is_key_record(&key[..key.len() - 2])); + assert!(!is_key_record(&[])); + } + + #[test] + fn scans_a_leveldb_log_and_keeps_the_newest_value() { + let key = fresh_key(); + let mut user_key = vec![0x00, 0x01, 0x02, 0x01, 0x01, KEY_ID.len() as u8]; + user_key.extend(KEY_ID.encode_utf16().flat_map(u16::to_be_bytes)); + let batch = |sequence: u64, value: &[u8]| { + let mut batch = sequence.to_le_bytes().to_vec(); + batch.extend(1u32.to_le_bytes()); + batch.push(1); + batch.extend(varint(user_key.len() as u64)); + batch.extend(&user_key); + batch.extend(varint(value.len() as u64)); + batch.extend(value); + batch + }; + let mut log = Vec::new(); + for (sequence, value) in [(5, b"stale".to_vec()), (9, record_value(&key))] { + let payload = batch(sequence, &value); + log.extend([0u8; 4]); + log.extend((payload.len() as u16).to_le_bytes()); + log.push(1); + log.extend(payload); + } + let mut found = HashMap::new(); + scan_log(&log, &mut found); + let record = found.get(&user_key).expect("record"); + assert_eq!(record.sequence, 9); + let decoded = key_from_record(&record.value).expect("key"); + assert_eq!(thumbprint(&decoded), thumbprint(&key)); + } + + #[test] + fn scans_a_table_block_with_prefix_compression() { + let mut user_key = vec![0x00, 0x01, 0x02, 0x01, 0x01, KEY_ID.len() as u8]; + user_key.extend(KEY_ID.encode_utf16().flat_map(u16::to_be_bytes)); + let mut internal = user_key.clone(); + internal.extend(((7u64 << 8) | 1).to_le_bytes()); + let key = fresh_key(); + let value = record_value(&key); + // Two entries: a full key, then the same key sharing all but its trailer. + let mut block = Vec::new(); + block.extend(varint(0)); + block.extend(varint(internal.len() as u64)); + block.extend(varint(value.len() as u64)); + block.extend(&internal); + block.extend(&value); + let mut second = user_key.clone(); + second.extend(((8u64 << 8) | 1).to_le_bytes()); + block.extend(varint(user_key.len() as u64)); + block.extend(varint(8)); + block.extend(varint(1)); + block.extend(&second[user_key.len()..]); + block.push(0); + block.extend(0u32.to_le_bytes()); + block.extend(1u32.to_le_bytes()); + let entries = block_entries(&block); + assert_eq!(entries.len(), 2); + assert_eq!(entries[1].0, second); + + let mut table = Vec::new(); + let data_handle = (table.len(), block.len()); + table.extend(&block); + table.extend([0, 0, 0, 0, 0]); + let mut index = Vec::new(); + let mut handle = varint(data_handle.0 as u64); + handle.extend(varint(data_handle.1 as u64)); + index.extend(varint(0)); + index.extend(varint(second.len() as u64)); + index.extend(varint(handle.len() as u64)); + index.extend(&second); + index.extend(&handle); + index.extend(0u32.to_le_bytes()); + index.extend(1u32.to_le_bytes()); + let index_handle = (table.len(), index.len()); + table.extend(&index); + table.extend([0, 0, 0, 0, 0]); + let mut footer = varint(0); + footer.extend(varint(0)); + footer.extend(varint(index_handle.0 as u64)); + footer.extend(varint(index_handle.1 as u64)); + footer.resize(40, 0); + footer.extend(TABLE_MAGIC); + table.extend(footer); + + let mut found = HashMap::new(); + scan_table(&table, &mut found); + let record = found.get(&user_key).expect("record"); + assert_eq!(record.sequence, 8); + assert!(!record.deleted); + } + + #[test] + fn proof_has_the_client_runtime_shape_and_verifies() { + use p256::ecdsa::{VerifyingKey, signature::Verifier}; + let key = fresh_key(); + let proof = create_proof( + &key, + "post", + "https://Host.example:443/api/auth/websocket-ticket/rpc?x=1#frag", + Some("access"), + 1_700_000_123_456, + ) + .unwrap(); + let parts: Vec<&str> = proof.split('.').collect(); + assert_eq!(parts.len(), 3); + let decode = |part: &str| { + let mut padded = part.replace('-', "+").replace('_', "/"); + while !padded.len().is_multiple_of(4) { + padded.push('='); + } + crate::catalog::b64::decode(&padded).unwrap() + }; + let header: serde_json::Value = serde_json::from_slice(&decode(parts[0])).unwrap(); + let claims: serde_json::Value = serde_json::from_slice(&decode(parts[1])).unwrap(); + assert_eq!(header["typ"], "dpop+jwt"); + assert_eq!(header["alg"], "ES256"); + assert_eq!(header["jwk"], public_jwk(&key)); + assert!(header["jwk"].get("d").is_none()); + assert_eq!(claims["htm"], "POST"); + assert_eq!( + claims["htu"], + "https://host.example/api/auth/websocket-ticket/rpc" + ); + assert_eq!(claims["iat"], 1_700_000_123); + assert_eq!(claims["jti"].as_str().unwrap().len(), 36); + // SHA-256("access"), base64url. + assert_eq!( + claims["ath"], + "oFYf1knNtrqnhAVfBRuteW6gr-8X_KOCGVSd7rpOjBo".to_owned() + ); + let signature = Signature::from_slice(&decode(parts[2])).unwrap(); + VerifyingKey::from(&key) + .verify(format!("{}.{}", parts[0], parts[1]).as_bytes(), &signature) + .unwrap(); + let no_ath = create_proof(&key, "GET", "http://h/ws", None, 0).unwrap(); + assert!(!no_ath.contains("ath")); + assert!(create_proof(&key, "GET", "not a url", None, 0).is_err()); + } + + #[test] + fn thumbprint_matches_rfc_7638_over_the_public_jwk() { + let key = fresh_key(); + let jwk = public_jwk(&key); + let canonical = format!( + r#"{{"crv":"P-256","kty":"EC","x":"{}","y":"{}"}}"#, + jwk["x"].as_str().unwrap(), + jwk["y"].as_str().unwrap() + ); + assert_eq!( + thumbprint(&key), + b64url_encode(&Sha256::digest(canonical.as_bytes())) + ); + } + + #[test] + fn base64url_matches_known_vectors() { + assert_eq!(b64url_encode(b""), ""); + assert_eq!(b64url_encode(b"f"), "Zg"); + assert_eq!(b64url_encode(b"fo"), "Zm8"); + assert_eq!(b64url_encode(&[0xfb, 0xff]), "-_8"); + } +} diff --git a/apps/desktop-gpui/src/environments.rs b/apps/desktop-gpui/src/environments.rs new file mode 100644 index 000000000000..fe55b506010c --- /dev/null +++ b/apps/desktop-gpui/src/environments.rs @@ -0,0 +1,92 @@ +//! The user's other saved environments (the Electron app's catalog): one connection each, whose +//! projects the picker uses to show where else a project lives. + +use crate::auth; +use crate::catalog::{self, SavedEnvironment}; +use crate::connection; +use crate::projects::EnvironmentProjects; +use crate::server; +use crate::status::{Status, StatusSender, publish}; +use std::path::PathBuf; +use tokio::time::sleep; + +/// Loads the catalog and keeps a connection to each reachable environment other than `primary_id`. +/// Any failure costs only the badges, and is logged once. +pub async fn run(primary_id: String, path: PathBuf, status: StatusSender) { + let loaded = tokio::task::spawn_blocking(move || catalog::load(&path)).await; + let catalog = match loaded { + Ok(Ok(catalog)) => catalog, + Ok(Err(message)) => return eprintln!("[environments] no environment badges: {message}"), + Err(error) => return eprintln!("[environments] no environment badges: {error}"), + }; + for (label, kind) in &catalog.skipped { + eprintln!("[environments] skipping {label}: {kind} connections are not supported"); + } + for notice in &catalog.notices { + eprintln!("[environments] {notice}"); + } + for saved in catalog + .environments + .into_iter() + .filter(|saved| saved.environment_id != primary_id) + { + tokio::spawn(follow(saved, status.clone())); + } +} + +/// Mirrors one environment's projects into `status` for as long as the app runs. +async fn follow(saved: SavedEnvironment, status: StatusSender) { + let client = auth::http_client(); + let (own_tx, own_rx) = async_channel::unbounded(); + // The connection ends its session when the action channel closes, so keep the sender here. + let (_actions_tx, actions_rx) = async_channel::unbounded(); + let mut backoff = connection::Backoff::new(); + let environment = loop { + match server::fetch_environment(&client, &saved.http_base).await { + Ok(environment) if environment.id == saved.environment_id => break environment, + Ok(_) => { + return eprintln!( + "[environments] {} answers as a different environment; not connecting", + saved.label + ); + } + Err(_) => sleep(backoff.next_delay()).await, + } + }; + let machine = environment.machine; + let connection = connection::Environment { + label: saved.label.clone(), + ..environment + }; + let forward = async { + let mut last: Option = None; + while let Ok(update) = own_rx.recv().await { + let projects = match update { + Status::Connected { + shell: Some(shell), .. + } => shell.project_entries, + Status::Connected { .. } => continue, + // A dropped socket must not leave a stale "Also on". + _ => Vec::new(), + }; + let next = EnvironmentProjects { + id: saved.environment_id.clone(), + label: saved.label.clone(), + machine, + projects, + }; + if last.as_ref() != Some(&next) { + publish(&status, Status::Environment(next.clone())); + last = Some(next); + } + } + }; + let session = connection::run( + &saved.http_base, + &saved.credential, + &connection, + &actions_rx, + &own_tx, + ); + tokio::join!(forward, session); +} diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 3c107ae90fcd..b2b06324884f 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -1,7 +1,10 @@ mod assets; mod auth; +mod catalog; mod config; mod connection; +mod electron_dpop; +mod environments; mod projects; mod rpc; mod server; @@ -46,6 +49,14 @@ fn main() { let client = auth::http_client(); match server::wait_until_ready(&client, &attach.http_base).await { Ok(environment) => { + // Only an attached server shares the Electron app's home, hence its catalog. + if let Some(path) = config::connection_catalog_path() { + tokio::spawn(environments::run( + environment.id.clone(), + path, + status_tx.clone(), + )); + } let credential = auth::Credential::Pairing(attach.pairing_token); connection::run( &attach.http_base, diff --git a/apps/desktop-gpui/src/projects.rs b/apps/desktop-gpui/src/projects.rs index b5bcc13f8b87..9e834311932a 100644 --- a/apps/desktop-gpui/src/projects.rs +++ b/apps/desktop-gpui/src/projects.rs @@ -1,7 +1,9 @@ -//! The project picker's rows, mirroring the web picker: `sortLogicalProjectsForSidebar` -//! (Sidebar.logic.ts), `pickableProjectGroups` (projectGrouping.ts), `deriveProjectIdentity` -//! (projectIdentity.ts) and `buildSidebarProjectPickerEntries` (sidebarProjectGrouping.ts). +//! The project picker's rows, mirroring the web picker in its default `repository` grouping mode: +//! `buildProjectGroups` and `pickableProjectGroups` (projectGrouping.ts), +//! `sortLogicalProjectsForSidebar` (Sidebar.logic.ts) and `buildSidebarProjectPickerEntries` +//! (sidebarProjectGrouping.ts). Each row is a group of projects that share a repository. +use crate::wire::generated::RepositoryIdentity; use crate::wire::{Project, Thread}; use std::collections::HashMap; @@ -31,18 +33,83 @@ const COLORS: [(&str, u32, u32); 18] = [ const SCRATCH_TITLE: &str = "No project"; const SCRATCH_FOLDER: &str = "scratch"; +/// `EnvironmentMachineKind` (packages/contracts/src/environment.ts); unknown values read as a server. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MachineKind { + Server, + Cloud, + Linux, + Desktop, + Laptop, + MacMini, + MacStudio, +} + +impl MachineKind { + pub fn parse(kind: &str) -> Self { + match kind { + "cloud" => Self::Cloud, + "linux" => Self::Linux, + "desktop" => Self::Desktop, + "laptop" => Self::Laptop, + "mac-mini" => Self::MacMini, + "mac-studio" => Self::MacStudio, + _ => Self::Server, + } + } +} + +/// Another environment that also has this project. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RemoteMember { + pub label: String, + pub machine: MachineKind, +} + +/// A saved environment other than the one this window attaches to, with its projects. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EnvironmentProjects { + pub id: String, + pub label: String, + pub machine: MachineKind, + pub projects: Vec, +} + +/// One picker row: a `ProjectGroup` of the web, seen from the attached environment. #[derive(Clone, Debug, PartialEq, Eq)] pub struct ProjectEntry { + /// The group key: the repository's canonical key, else the project's physical path. + pub key: String, + /// The target project (the representative): what picking the row selects. pub id: String, + /// Every project of the group on the attached environment, the pin writes all of them. + pub member_ids: Vec, + /// The group label (`deriveProjectGroupLabel`). pub title: String, + /// From the representative: its monogram override, else its title. pub initials: String, /// Index into the shared palette. pub color: usize, + /// Any member is pinned. pub pinned: bool, pub scratch: bool, + /// The repository key shared across environments, when the group has a repository. + pub logical_key: Option, + /// Other environments that have the same logical project. + pub remote: Vec, } impl ProjectEntry { + pub fn contains(&self, project_id: &str) -> bool { + self.member_ids.iter().any(|id| id == project_id) + } + + /// The project picking the row selects: the active one when it belongs to the group, as the + /// web's `targetProject`, else the representative. + pub fn target<'a>(&'a self, active_id: Option<&'a str>) -> &'a str { + active_id.filter(|id| self.contains(id)).unwrap_or(&self.id) + } + /// The badge text color for the theme; the badge background is this at 14% alpha. pub fn color_hex(&self, dark: bool) -> u32 { let (_, light, night) = COLORS[self.color]; @@ -100,6 +167,18 @@ fn color_index(name: &str) -> usize { }) } +/// The cross-environment identity of `projectGrouping.ts` in the default `repository` mode: the +/// repository's canonical key. Without one the web falls back to a per-environment path key, which +/// never matches another environment, so there is nothing to join on. +fn logical_key(project: &Project) -> Option { + let identity = project.repository_identity.as_ref()?; + let key = identity + .origin + .as_ref() + .map_or(&identity.canonical_key, |origin| &origin.canonical_key); + (!key.is_empty() && !key.contains('\0')).then(|| key.clone()) +} + /// A monogram icon override wins, as in `ProjectFavicon`; other icon kinds fall back to the derived one. fn monogram_override(project: &Project) -> Option<(String, usize)> { let icon = project.project_icon.as_ref()?; @@ -131,8 +210,159 @@ fn thread_activity(thread: &Thread) -> &str { .unwrap_or(&thread.updated_at) } -/// Unarchived projects, pinned first, then most recent thread activity (the project's own -/// `updatedAt` when it has no threads), then title. +/// `normalizeProjectPathForComparison`: trimmed, no trailing separators, Windows paths folded. +fn normalize_path(path: &str) -> String { + let trimmed = path.trim(); + let stripped = trimmed.trim_end_matches(['/', '\\']); + let normalized = if stripped.is_empty() && !trimmed.is_empty() { + &trimmed[..1] + } else { + stripped + }; + let bytes = normalized.as_bytes(); + let drive = bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':'; + if drive || normalized.starts_with("\\\\") { + normalized.replace('/', "\\").to_lowercase() + } else { + normalized.to_owned() + } +} + +/// `shouldReplacePhysicalProjectWinner`: the fresher project wins, then the greater id. Server +/// timestamps are fixed-width UTC, so text order is time order. +fn fresher(candidate: &Project, existing: &Project) -> bool { + let key = |project: &Project| (project.updated_at.clone(), project.id.clone()); + key(candidate) > key(existing) +} + +fn unique_non_empty<'a>(values: impl IntoIterator>) -> Vec<&'a str> { + let mut unique: Vec<&str> = Vec::new(); + for value in values.into_iter().flatten().map(str::trim) { + if !value.is_empty() && !unique.contains(&value) { + unique.push(value); + } + } + unique +} + +/// `repositoryGroupingDisplayNameOf`. +fn grouping_display_name(identity: &RepositoryIdentity) -> Option<&str> { + match &identity.origin { + Some(origin) => Some( + origin + .display_name + .as_deref() + .unwrap_or(&origin.canonical_key), + ), + None => identity.display_name.as_deref(), + } +} + +/// `deriveProjectGroupLabel`, for groups of more than one member. +fn group_label(representative: &Project, members: &[&Project]) -> String { + let titles = unique_non_empty(members.iter().map(|m| Some(m.title.as_str()))); + let display_names = unique_non_empty(members.iter().map(|m| { + m.repository_identity + .as_ref() + .and_then(grouping_display_name) + })); + let names = unique_non_empty(members.iter().map(|m| { + m.repository_identity + .as_ref() + .and_then(|i| i.name.as_deref()) + })); + if let [title] = titles[..] + && !display_names.contains(&title) + && !names.contains(&title) + { + return title.to_owned(); + } + if let [name] = display_names[..] { + return name.to_owned(); + } + if let [name] = names[..] { + return name.to_owned(); + } + representative.title.clone() +} + +/// Group keys without a repository; the NUL keeps them out of the repository key space. +const PATH_KEY_PREFIX: &str = "\0path:"; + +/// A group under construction: the physical winners, plus every project they stand for. +struct Group<'a> { + key: String, + winners: Vec<&'a Project>, + all: Vec<&'a Project>, +} + +/// `buildProjectGroups` in `repository` mode: projects with the same normalized workspace root +/// collapse to the freshest, then groups form on the repository key (the project's own path when +/// it has none). The web's `projects` input lists a server's projects in snapshot order, which is +/// creation order, so members are ordered by `createdAt` here (the shell state is a map) and the +/// first one is the representative. Every project here is on the attached environment. +fn build_groups<'a>(projects: impl IntoIterator) -> Vec> { + let mut ordered: Vec<&Project> = projects.into_iter().collect(); + ordered.sort_by(|a, b| (&a.created_at, &a.id).cmp(&(&b.created_at, &b.id))); + + let mut physical: Vec<(String, Vec<&Project>)> = Vec::new(); + for project in ordered { + let key = normalize_path(&project.workspace_root); + match physical.iter_mut().find(|(existing, _)| *existing == key) { + Some((_, members)) => members.push(project), + None => physical.push((key, vec![project])), + } + } + + let mut groups: Vec = Vec::new(); + for (path, projects) in physical { + let winner = projects + .iter() + .copied() + .reduce(|current, candidate| { + if fresher(candidate, current) { + candidate + } else { + current + } + }) + .expect("a physical key has a project"); + // `selectProjectIdentitySource`: a winner without a repository borrows the freshest identity. + let source = if winner.repository_identity.is_some() { + winner + } else { + projects + .iter() + .copied() + .filter(|project| project.repository_identity.is_some()) + .reduce(|current, candidate| { + if fresher(candidate, current) { + candidate + } else { + current + } + }) + .unwrap_or(winner) + }; + let key = logical_key(source).unwrap_or_else(|| format!("{PATH_KEY_PREFIX}{path}")); + match groups.iter_mut().find(|group| group.key == key) { + Some(group) => { + group.winners.push(winner); + group.all.extend(projects); + } + None => groups.push(Group { + key, + winners: vec![winner], + all: projects, + }), + } + } + groups +} + +/// The picker's groups: pinned first, then most recent thread activity across the group (the +/// representative's `updatedAt` when it has no threads), then label. Groups whose members are all +/// archived are left out. pub fn build_entries<'a>( projects: impl IntoIterator, threads: impl IntoIterator, @@ -143,42 +373,111 @@ pub fn build_entries<'a>( let slot = latest.entry(thread.project_id.as_str()).or_insert(activity); *slot = (*slot).max(activity); } - let mut ranked: Vec<(&Project, &str)> = projects + let mut ranked: Vec<(ProjectEntry, String)> = build_groups(projects) .into_iter() - .filter(|project| project.archived_at.is_none()) - .map(|project| { - let activity = latest - .get(project.id.as_str()) - .copied() - .unwrap_or(&project.updated_at); - (project, activity) + .filter(|group| group.winners.iter().any(|p| p.archived_at.is_none())) + .map(|group| { + let representative = group.winners[0]; + let label = if group.winners.len() > 1 { + group_label(representative, &group.winners) + } else { + representative.title.clone() + }; + let (initials, color) = monogram_override(representative).unwrap_or_else(|| { + ( + initials(&representative.title), + color_index(&representative.title), + ) + }); + let activity = group + .all + .iter() + .filter_map(|p| latest.get(p.id.as_str()).copied()) + .max() + .unwrap_or(&representative.updated_at) + .to_owned(); + let entry = ProjectEntry { + logical_key: (!group.key.starts_with(PATH_KEY_PREFIX)).then(|| group.key.clone()), + key: group.key, + id: representative.id.clone(), + member_ids: group.all.iter().map(|p| p.id.clone()).collect(), + title: label, + initials, + color, + pinned: group.winners.iter().any(|p| p.pinned_at.is_some()), + scratch: is_scratch(representative), + remote: Vec::new(), + }; + (entry, activity) }) .collect(); ranked.sort_by(|(a, a_at), (b, b_at)| { - b.pinned_at - .is_some() - .cmp(&a.pinned_at.is_some()) + b.pinned + .cmp(&a.pinned) .then_with(|| b_at.cmp(a_at)) .then_with(|| a.title.to_lowercase().cmp(&b.title.to_lowercase())) - .then_with(|| a.id.cmp(&b.id)) + .then_with(|| a.key.cmp(&b.key)) }); - ranked - .into_iter() - .map(|(project, _)| { - let (initials, color) = monogram_override(project) - .unwrap_or_else(|| (initials(&project.title), color_index(&project.title))); - ProjectEntry { - id: project.id.clone(), - title: project.title.clone(), - initials, - color, - pinned: project.pinned_at.is_some(), - scratch: is_scratch(project), + ranked.into_iter().map(|(entry, _)| entry).collect() +} + +/// Adds to each entry the other environments that have the same logical project. +pub fn with_remote_members( + entries: &[ProjectEntry], + others: &[EnvironmentProjects], +) -> Vec { + entries + .iter() + .map(|entry| { + let mut entry = entry.clone(); + if let Some(key) = &entry.logical_key { + entry.remote = others + .iter() + .filter(|other| { + other + .projects + .iter() + .any(|project| project.logical_key.as_ref() == Some(key)) + }) + .map(|other| RemoteMember { + label: if other.label.trim().is_empty() { + "Remote".to_owned() + } else { + other.label.clone() + }, + machine: other.machine, + }) + .collect(); } + entry }) .collect() } +/// Members ordered by label, as `ProjectEnvironmentBadge` sorts them. +fn sorted_members(members: &[RemoteMember]) -> Vec<&RemoteMember> { + let mut sorted: Vec<&RemoteMember> = members.iter().collect(); + sorted.sort_by_cached_key(|member| (member.label.to_lowercase(), member.label.clone())); + sorted +} + +/// The environment badge's tooltip. Picker rows are always on the attached environment, so this is +/// the web's "Also on" case; "On" only applies to groups the attached environment lacks. +pub fn badge_text(members: &[RemoteMember]) -> Option { + let mut labels: Vec<&str> = Vec::new(); + for member in sorted_members(members) { + if !labels.contains(&member.label.as_str()) { + labels.push(&member.label); + } + } + (!labels.is_empty()).then(|| format!("Also on {}", labels.join(", "))) +} + +/// The badge icon follows the first member by label. +pub fn badge_machine(members: &[RemoteMember]) -> Option { + sorted_members(members).first().map(|member| member.machine) +} + /// What the picker lists: no scratch project, the active project leading the unpinned ones /// (pinned stay first), then the case-insensitive title filter. pub fn picker_entries<'a>( @@ -189,7 +488,7 @@ pub fn picker_entries<'a>( let mut listed: Vec<&ProjectEntry> = entries.iter().filter(|entry| !entry.scratch).collect(); if let Some(at) = listed .iter() - .position(|entry| Some(entry.id.as_str()) == active_id && !entry.pinned) + .position(|entry| active_id.is_some_and(|id| entry.contains(id)) && !entry.pinned) { let active = listed.remove(at); let pinned = listed.iter().filter(|entry| entry.pinned).count(); @@ -200,8 +499,8 @@ pub fn picker_entries<'a>( listed } -/// Rows in the order they had when the picker opened (`useOrderWhileOpen`): ids that left drop -/// out, ids that arrived follow in their current order. +/// Rows in the order they had when the picker opened (`useOrderWhileOpen`): keys that left drop +/// out, keys that arrived follow in their current order. pub fn order_while_open<'a>( listed: Vec<&'a ProjectEntry>, opened_with: &[String], @@ -209,7 +508,7 @@ pub fn order_while_open<'a>( let position = |entry: &ProjectEntry| { opened_with .iter() - .position(|id| *id == entry.id) + .position(|key| *key == entry.key) .unwrap_or(opened_with.len()) }; let mut ordered = listed; @@ -227,12 +526,16 @@ mod tests { #[test] fn order_while_open_keeps_the_opening_order_and_appends_new_rows() { let entry = |id: &str, pinned: bool| ProjectEntry { + key: id.to_owned(), id: id.to_owned(), + member_ids: vec![id.to_owned()], title: id.to_owned(), initials: String::new(), color: 0, pinned, scratch: false, + logical_key: None, + remote: Vec::new(), }; let opened = ["a", "b", "c"].map(str::to_owned); let now = [entry("d", false), entry("c", true), entry("a", false)]; @@ -269,6 +572,9 @@ mod tests { project["archivedAt"] = Value::Null; project["pinnedAt"] = Value::Null; project["updatedAt"] = "2026-01-01T00:00:00.000Z".into(); + project["createdAt"] = "2026-01-01T00:00:00.000Z".into(); + project["workspaceRoot"] = format!("/work/{id}").into(); + project["repositoryIdentity"] = Value::Null; edit(&mut project); serde_json::from_value(project).expect("project decodes") } @@ -416,4 +722,230 @@ mod tests { ); assert!(picker_entries(&entries, None, "zzz").is_empty()); } + + fn repo(key: &str) -> serde_json::Value { + json!({ + "canonicalKey": key, + "locator": {"remoteName": "origin", "remoteUrl": "git@example.invalid:o/r.git", "source": "git-remote"}, + }) + } + + #[test] + fn logical_key_prefers_the_origin_key_and_needs_a_repository() { + let plain = project("a", "a", |p| p["repositoryIdentity"] = repo("host/o/r")); + let forked = project("b", "b", |p| { + let mut identity = repo("host/fork/r"); + identity["origin"] = json!({"canonicalKey": "host/o/r"}); + p["repositoryIdentity"] = identity; + }); + let bare = project("c", "c", |p| p["repositoryIdentity"] = Value::Null); + let keys: Vec<_> = [&plain, &forked, &bare] + .iter() + .map(|p| logical_key(p)) + .collect(); + assert_eq!( + keys, + [ + Some("host/o/r".to_owned()), + Some("host/o/r".to_owned()), + None + ] + ); + } + + #[test] + fn remote_members_join_on_the_logical_key() { + let entries = build_entries( + &[ + project("a", "atlas", |p| { + p["repositoryIdentity"] = repo("host/o/atlas") + }), + project("b", "solo", |p| p["repositoryIdentity"] = Value::Null), + ], + [], + ); + let other = |label: &str, machine, key: &str| EnvironmentProjects { + id: label.to_owned(), + label: label.to_owned(), + machine, + projects: build_entries( + &[project("r", "atlas", |p| { + p["repositoryIdentity"] = repo(key) + })], + [], + ), + }; + let others = [ + other("devbox1", MachineKind::Linux, "host/o/atlas"), + other("elsewhere", MachineKind::Cloud, "host/o/other"), + ]; + let joined = with_remote_members(&entries, &others); + let by_title = |t: &str| joined.iter().find(|e| e.title == t).unwrap(); + assert_eq!( + by_title("atlas").remote, + [RemoteMember { + label: "devbox1".to_owned(), + machine: MachineKind::Linux + }] + ); + assert!(by_title("solo").remote.is_empty()); + + // A group on the other environment joins the same way, through its repository key. + let grouped = EnvironmentProjects { + projects: build_entries( + &[ + project("r1", "atlas", |p| { + p["repositoryIdentity"] = repo("host/o/atlas") + }), + project("r2", "api", |p| { + p["repositoryIdentity"] = repo("host/o/atlas") + }), + ], + [], + ), + ..other("devbox2", MachineKind::Server, "x") + }; + let joined = with_remote_members(&entries, &[grouped]); + assert_eq!(by_title("atlas").remote.len(), 1); + assert_eq!( + joined.iter().find(|e| e.title == "atlas").unwrap().remote[0].label, + "devbox2" + ); + } + + #[test] + fn badge_lists_unique_labels_and_takes_the_first_machine() { + let member = |label: &str, machine| RemoteMember { + label: label.to_owned(), + machine, + }; + assert_eq!(badge_text(&[]), None); + assert_eq!(badge_machine(&[]), None); + let members = [ + member("devbox1", MachineKind::Linux), + member("Air", MachineKind::Laptop), + member("devbox1", MachineKind::Linux), + ]; + assert_eq!( + badge_text(&members).as_deref(), + Some("Also on Air, devbox1") + ); + assert_eq!(badge_machine(&members), Some(MachineKind::Laptop)); + assert_eq!(MachineKind::parse("mac-studio"), MachineKind::MacStudio); + assert_eq!(MachineKind::parse("quantum"), MachineKind::Server); + } + + fn named_repo(key: &str, display: &str, name: &str) -> Value { + let mut identity = repo(key); + identity["displayName"] = display.into(); + identity["name"] = name.into(); + identity + } + + #[test] + fn same_repository_projects_form_one_group_like_the_web() { + let identity = || named_repo("host/trogon/t3code", "TrogonStack/t3code", "t3code"); + let projects = [ + project("root", "t3code", |p| { + p["workspaceRoot"] = "/work/t3code/".into(); + p["repositoryIdentity"] = identity(); + p["createdAt"] = "2026-01-01T00:00:00.000Z".into(); + p["projectIcon"] = json!({"kind": "monogram", "text": "ZZ", "color": "rose"}); + }), + project("server", "server", |p| { + p["workspaceRoot"] = "/work/t3code/apps/server".into(); + p["repositoryIdentity"] = identity(); + p["createdAt"] = "2026-02-01T00:00:00.000Z".into(); + p["pinnedAt"] = "2026-03-01T00:00:00.000Z".into(); + }), + // Same physical path as "root": the fresher one wins, the loser still counts as a member. + project("root-dup", "t3code", |p| { + p["workspaceRoot"] = "/work/t3code".into(); + p["updatedAt"] = "2026-01-02T00:00:00.000Z".into(); + p["createdAt"] = "2026-03-01T00:00:00.000Z".into(); + }), + project("lone", "lone", |_| {}), + ]; + let entries = build_entries(&projects, []); + assert_eq!( + titles(&entries.iter().collect::>()), + ["TrogonStack/t3code", "lone"] + ); + let group = &entries[0]; + assert_eq!(group.key, "host/trogon/t3code"); + assert_eq!(group.logical_key.as_deref(), Some("host/trogon/t3code")); + // The duplicate path takes its identity from the freshest identified project, and the + // oldest winner (root-dup replaced root as winner) leads. + assert_eq!(group.member_ids, ["root", "root-dup", "server"]); + assert_eq!(group.id, "root-dup"); + assert!(group.pinned, "any member pinned"); + assert!(group.contains("server") && !group.contains("lone")); + assert_eq!(group.target(Some("server")), "server"); + assert_eq!(group.target(Some("lone")), "root-dup"); + assert_eq!(entries[1].key, "\0path:/work/lone"); + assert_eq!(entries[1].logical_key, None); + // The icon comes from the representative: root-dup has none, so its title is used. + assert_eq!(group.initials, "T3"); + + let mixed = [ + project("a", "a", |p| { + p["repositoryIdentity"] = repo("k"); + p["archivedAt"] = "2026-01-01T00:00:00.000Z".into(); + }), + project("b", "b", |p| p["repositoryIdentity"] = repo("k")), + ]; + assert_eq!( + build_entries(&mixed, []).len(), + 1, + "archived only when every member is" + ); + let all_archived = [ + project("a", "a", |p| { + p["repositoryIdentity"] = repo("k"); + p["archivedAt"] = "2026-01-01T00:00:00.000Z".into(); + }), + project("b", "b", |p| { + p["repositoryIdentity"] = repo("k"); + p["archivedAt"] = "2026-01-01T00:00:00.000Z".into(); + }), + ]; + assert!(build_entries(&all_archived, []).is_empty()); + } + + #[test] + fn group_label_follows_derive_project_group_label() { + let label = |rows: &[(&str, Value)]| { + let projects: Vec = rows + .iter() + .enumerate() + .map(|(index, (title, identity))| { + project(&format!("p{index}"), title, |p| { + p["repositoryIdentity"] = identity.clone(); + p["createdAt"] = format!("2026-01-0{}T00:00:00.000Z", index + 1).into(); + }) + }) + .collect(); + build_entries(&projects, [])[0].title.clone() + }; + let named = || named_repo("k", "Org/app", "app"); + let nameless = || { + let mut identity = repo("k"); + identity["name"] = "app".into(); + identity + }; + // A shared title wins unless it is the display or repository name. + assert_eq!(label(&[("mono", named()), ("mono", named())]), "mono"); + assert_eq!( + label(&[("Org/app", named()), ("Org/app", named())]), + "Org/app" + ); + assert_eq!(label(&[("app", named()), ("app", named())]), "Org/app"); + // Different titles: the single display name, else the single repository name. + assert_eq!(label(&[("app", named()), ("server", named())]), "Org/app"); + assert_eq!(label(&[("web", nameless()), ("server", nameless())]), "app"); + // Nothing shared: the representative's title. + assert_eq!(label(&[("web", repo("k")), ("server", repo("k"))]), "web"); + // A lone project keeps its own title. + assert_eq!(label(&[("app", named())]), "app"); + } } diff --git a/apps/desktop-gpui/src/server.rs b/apps/desktop-gpui/src/server.rs index a752fadefd30..2b760d331bd2 100644 --- a/apps/desktop-gpui/src/server.rs +++ b/apps/desktop-gpui/src/server.rs @@ -6,6 +6,7 @@ use crate::auth; use crate::config::{Config, ServerCommand}; use crate::connection::{self, ActionReceiver, Backoff, Environment}; +use crate::projects::MachineKind; use crate::status::{Status, StatusSender, publish}; use serde::{Deserialize, Serialize}; use std::io; @@ -161,38 +162,65 @@ fn forward_output(stream: impl AsyncRead + Unpin + Send + 'static) { #[derive(Deserialize)] struct Descriptor { + #[serde(rename = "environmentId", default)] + environment_id: String, label: String, #[serde(default)] + platform: Platform, + #[serde(default)] capabilities: Capabilities, } +#[derive(Default, Deserialize)] +struct Platform { + machine: Option, +} + #[derive(Default, Deserialize)] struct Capabilities { #[serde(rename = "projectOrganization", default)] project_organization: bool, } +/// One attempt at the environment descriptor. +pub async fn fetch_environment( + client: &reqwest::Client, + http_base: &str, +) -> Result { + let response = client + .get(format!("{http_base}{READINESS_PATH}")) + .timeout(READINESS_REQUEST_TIMEOUT) + .send() + .await + .map_err(|error| error.without_url().to_string())?; + if !response.status().is_success() { + return Err(format!("HTTP {}", response.status().as_u16())); + } + let descriptor: Descriptor = response + .json() + .await + .map_err(|error| error.without_url().to_string())?; + Ok(Environment { + id: descriptor.environment_id, + label: descriptor.label, + machine: descriptor + .platform + .machine + .as_deref() + .map_or(MachineKind::Server, MachineKind::parse), + project_organization: descriptor.capabilities.project_organization, + }) +} + /// Polls until the descriptor answers, returning what the connection needs from it. pub async fn wait_until_ready( client: &reqwest::Client, http_base: &str, ) -> Result { - let url = format!("{http_base}{READINESS_PATH}"); let deadline = Instant::now() + READINESS_TIMEOUT; loop { - let attempt = client - .get(&url) - .timeout(READINESS_REQUEST_TIMEOUT) - .send() - .await; - if let Ok(response) = attempt - && response.status().is_success() - && let Ok(descriptor) = response.json::().await - { - return Ok(Environment { - label: descriptor.label, - project_organization: descriptor.capabilities.project_organization, - }); + if let Ok(environment) = fetch_environment(client, http_base).await { + return Ok(environment); } if Instant::now() >= deadline { return Err(format!( diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index 0274a48ef221..f1559520ad80 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -1,4 +1,4 @@ -use crate::projects::ProjectEntry; +use crate::projects::{EnvironmentProjects, ProjectEntry}; use crate::wire::generated::ThreadsEntryStatus; use std::fmt; @@ -16,6 +16,8 @@ pub enum Status { shell: Option, }, Failed(String), + /// Another saved environment's projects; says nothing about the primary connection. + Environment(EnvironmentProjects), } /// What the window needs from the shell state, newest thread first. @@ -69,6 +71,7 @@ impl fmt::Display for Status { plural(*threads, "thread"), ), Self::Failed(message) => f.write_str(message), + Self::Environment(other) => write!(f, "Environment {}", other.label), } } } diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 78720aa53f5d..26b4652087e8 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -1,5 +1,5 @@ use crate::connection::{Action, ActionSender}; -use crate::projects::{self, ProjectEntry}; +use crate::projects::{self, EnvironmentProjects, MachineKind, ProjectEntry}; use crate::status::{ShellView, Status, ThreadRow}; use crate::wire::generated::ThreadsEntryStatus as ThreadStatus; use gpui::{ @@ -32,6 +32,8 @@ pub struct MainView { selected_project: Option, picker_open: bool, picker_order: Option>, + /// The other saved environments, by id. + environments: Vec, actions: ActionSender, } @@ -63,11 +65,19 @@ impl MainView { selected_project: None, picker_open: false, picker_order: None, + environments: Vec::new(), actions, } } pub fn set_status(&mut self, status: Status, cx: &mut Context) { + if let Status::Environment(other) = status { + match self.environments.iter_mut().find(|e| e.id == other.id) { + Some(known) => *known = other, + None => self.environments.push(other), + } + return cx.notify(); + } self.status = status; // Thread activity reorders recency on every delta, so the default must not follow it. if self.selected_project.is_none() { @@ -94,8 +104,8 @@ impl MainView { let shell = self.shell()?; let active = active_project(self.selected_project.as_deref(), shell); let entries = &shell.project_entries; - let listed = projects::picker_entries(entries, active.map(|e| e.id.as_str()), ""); - Some(listed.into_iter().map(|entry| entry.id.clone()).collect()) + let listed = projects::picker_entries(entries, selected_id(self, active), ""); + Some(listed.into_iter().map(|entry| entry.key.clone()).collect()) } fn environment(&self) -> String { @@ -170,8 +180,8 @@ impl MainView { .gap_6() .child(self.hero( &project, - active.map(|entry| entry.id.clone()), - shell.project_entries.clone(), + selected_id(self, active).map(str::to_owned), + projects::with_remote_members(&shell.project_entries, &self.environments), cx, )) .child(self.composer_box(cx)), @@ -219,11 +229,11 @@ impl MainView { let mono = theme.mono_font_family.clone(); let border = theme.border; let rows = listed.iter().enumerate().map(|(index, entry)| { - let (view, id) = (view.clone(), entry.id.clone()); - let (actions, pin_id) = (actions.clone(), entry.id.clone()); + let (view, id) = (view.clone(), entry.target(active_id.as_deref()).to_owned()); + let (actions, pin_ids) = (actions.clone(), entry.member_ids.clone()); let pinned = entry.pinned; let color: Hsla = rgb(entry.color_hex(dark)).into(); - let active = active_id.as_deref() == Some(entry.id.as_str()); + let active = active_id.as_deref().is_some_and(|id| entry.contains(id)); h_flex() .id(("project", index)) .group(PICKER_ROW_GROUP) @@ -259,7 +269,7 @@ impl MainView { .child(entry.title.clone()), ) .when(can_pin, |row| { - row.child(pin_button(index, entry, actions, pin_id)) + row.child(pin_button(index, entry, actions, pin_ids)) }) .when(!can_pin && pinned, |row| { row.child( @@ -268,6 +278,7 @@ impl MainView { .text_color(muted), ) }) + .children(environment_badge(index, entry, muted)) .on_click(move |_, _, cx| { view.update(cx, |this, cx| { this.selected_project = Some(id.clone()); @@ -421,6 +432,36 @@ impl RenderOnce for ProjectTrigger { } } +fn machine_icon(kind: MachineKind) -> gpui_kit_assets::IconName { + use gpui_kit_assets::IconName as Icons; + match kind { + MachineKind::Server => Icons::Server, + MachineKind::Cloud => Icons::Cloud, + MachineKind::Linux => Icons::Terminal, + MachineKind::Desktop => Icons::Monitor, + MachineKind::Laptop => Icons::Laptop, + MachineKind::MacMini => Icons::HardDrive, + MachineKind::MacStudio => Icons::Computer, + } +} + +/// The web's `ProjectEnvironmentBadge`: a machine icon with "Also on ", right of the pin. +fn environment_badge( + index: usize, + entry: &ProjectEntry, + muted: Hsla, +) -> Option> { + let text = projects::badge_text(&entry.remote)?; + let machine = projects::badge_machine(&entry.remote)?; + Some( + div() + .id(("environment", index)) + .flex_shrink_0() + .tooltip(move |window, cx| Tooltip::new(text.clone()).build(window, cx)) + .child(Icon::new(machine_icon(machine)).small().text_color(muted)), + ) +} + const PICKER_ROW_GROUP: &str = "project-row"; /// Shown on row hover, and always for a pinned project. A click toggles the pin and, because @@ -429,7 +470,7 @@ fn pin_button( index: usize, entry: &ProjectEntry, actions: ActionSender, - project_id: String, + project_ids: Vec, ) -> impl IntoElement + use<> { let pinned = entry.pinned; let icon = |name| Icon::new(name).small(); @@ -461,19 +502,35 @@ fn pin_button( div().child(icon(gpui_kit_assets::IconName::Pin)) }) .on_click(move |_, _, _| { - let action = Action::SetProjectPinned { - project_id: project_id.clone(), - pinned: !pinned, - }; - if actions.try_send(action).is_err() { - eprintln!("[ui] could not queue the pin request"); + // Clients write every member of the group together. + for project_id in &project_ids { + let action = Action::SetProjectPinned { + project_id: project_id.clone(), + pinned: !pinned, + }; + if actions.try_send(action).is_err() { + eprintln!("[ui] could not queue the pin request"); + } } }) } +/// The picked project id while its row still exists, else the active row's target. +fn selected_id<'a>(view: &'a MainView, active: Option<&'a ProjectEntry>) -> Option<&'a str> { + view.selected_project + .as_deref() + .filter(|id| active.is_some_and(|entry| entry.contains(id))) + .or(active.map(|entry| entry.id.as_str())) +} + /// The picked project while it still exists, else the newest thread's project, else the first listed. fn active_project<'a>(selected: Option<&str>, shell: &'a ShellView) -> Option<&'a ProjectEntry> { - let by_id = |id: &str| shell.project_entries.iter().find(|entry| entry.id == id); + let by_id = |id: &str| { + shell + .project_entries + .iter() + .find(|entry| entry.contains(id)) + }; selected .and_then(by_id) .or_else(|| { @@ -579,12 +636,16 @@ mod tests { fn entry(id: &str, scratch: bool) -> ProjectEntry { ProjectEntry { + key: id.to_owned(), id: id.to_owned(), + member_ids: vec![id.to_owned()], title: id.to_owned(), initials: "XX".to_owned(), color: 0, pinned: false, scratch, + logical_key: None, + remote: Vec::new(), } } From 932e357502405528d24d0e661e79b9707a184e8a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 5 Nov 2026 02:33:40 -0500 Subject: [PATCH 10/82] fix(desktop-gpui): thread list no longer reorders on every status change Rows were sorted by updatedAt, which the server bumps on each status change, so the list jumped while agents worked. The list now follows the web sidebar's default sections and anchors: pinned and active threads first, then collapsible Working, Snoozed and Settled shelves, each sorted the way the web sorts it. Signed-off-by: Yordis Prieto --- apps/desktop-gpui/README.md | 2 +- apps/desktop-gpui/src/shell.rs | 615 ++++++++++++++++++++++++++++++-- apps/desktop-gpui/src/status.rs | 15 +- apps/desktop-gpui/src/ui.rs | 156 +++++++- 4 files changed, 753 insertions(+), 35 deletions(-) diff --git a/apps/desktop-gpui/README.md b/apps/desktop-gpui/README.md index f4647bc2a7dc..54e901b44ef8 100644 --- a/apps/desktop-gpui/README.md +++ b/apps/desktop-gpui/README.md @@ -2,7 +2,7 @@ Native desktop client written in Rust with [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui). It is meant to replace `apps/desktop` (Electron) over time and never embeds Node or Chromium. It starts the T3 server as a child process, authenticates without pairing, and opens the RPC WebSocket. -Status: connection shell. The window shows the connection state ("Starting server...", "Connecting...", "Connected to · N projects · M threads", or the error). The counts come from a live `orchestration.subscribeShell` subscription. Once connected, it draws a static skeleton of the main screen with [gpui-component](https://github.com/longbridge/gpui-component): a thread list (newest first) and an empty composer. Nothing is interactive yet. +Status: connection shell. The window shows the connection state ("Starting server...", "Connecting...", "Connected to · N projects · M threads", or the error). The counts come from a live `orchestration.subscribeShell` subscription. Once connected, it draws a static skeleton of the main screen with [gpui-component](https://github.com/longbridge/gpui-component): a thread list laid out like the web sidebar (pinned and active threads, then collapsible Working, Snoozed and Settled shelves) and an empty composer. Only the shelf headers are interactive. ## Run diff --git a/apps/desktop-gpui/src/shell.rs b/apps/desktop-gpui/src/shell.rs index c5471e51e907..15aa77c499bb 100644 --- a/apps/desktop-gpui/src/shell.rs +++ b/apps/desktop-gpui/src/shell.rs @@ -2,14 +2,287 @@ //! `packages/client-runtime/src/state/shellReducer.ts` for what this client shows. use crate::projects; -use crate::status::{ShellView, ThreadRow}; +use crate::status::{Section, ShellView, ThreadRow}; use crate::wire::generated::{ OrchestrationSubscribeShellSuccess as Item, OrchestrationSubscribeShellSuccessVariant4Location as Location, OrchestrationSubscribeShellSuccessVariant5Location as RemovedLocation, }; +use crate::wire::generated::{ + ThreadsEntryActivityRunStatus as Activity, ThreadsEntryInteractionMode as InteractionMode, + ThreadsEntryPendingRuntimeRequestKind as RequestKind, ThreadsEntrySettledOverride as Override, + ThreadsEntryStatus as RunStatus, +}; use crate::wire::{Project, Thread}; +use std::cmp::Ordering; use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Epoch milliseconds of an RFC 3339 timestamp, as `Date.parse` reads the server's. +fn timestamp_ms(text: &str) -> Option { + let bytes = text.as_bytes(); + let number = |from: usize, len: usize| -> Option { + let digits = text.get(from..from + len)?; + digits + .bytes() + .all(|b| b.is_ascii_digit()) + .then(|| digits.parse().ok())? + }; + let (year, month, day) = (number(0, 4)?, number(5, 2)?, number(8, 2)?); + let (hour, minute, second) = (number(11, 2)?, number(14, 2)?, number(17, 2)?); + if bytes.get(4) != Some(&b'-') + || bytes.get(7) != Some(&b'-') + || !matches!(bytes.get(10), Some(b'T' | b't' | b' ')) + || bytes.get(13) != Some(&b':') + || bytes.get(16) != Some(&b':') + || !(1..=12).contains(&month) + || !(1..=31).contains(&day) + || hour > 23 + || minute > 59 + || second > 59 + { + return None; + } + let mut at = 19; + let mut millis = 0; + if bytes.get(at) == Some(&b'.') { + let digits = text[at + 1..] + .bytes() + .take_while(u8::is_ascii_digit) + .count(); + if digits == 0 { + return None; + } + let fraction = &text[at + 1..at + 1 + digits.min(3)]; + millis = fraction.parse::().ok()? * 10_i64.pow(3 - fraction.len() as u32); + at += 1 + digits; + } + let offset_minutes = match text.get(at..)? { + "Z" | "z" => 0, + zone if zone.len() == 6 && (zone.starts_with('+') || zone.starts_with('-')) => { + let hours = number(at + 1, 2)?; + let minutes = number(at + 4, 2)?; + let total = hours * 60 + minutes; + if zone.starts_with('-') { -total } else { total } + } + _ => return None, + }; + let (y, m) = if month <= 2 { + (year - 1, month + 9) + } else { + (year, month - 3) + }; + let era = y.div_euclid(400); + let year_of_era = y - era * 400; + let day_of_year = (153 * m + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + let days = era * 146_097 + day_of_era - 719_468; + let seconds = days * 86_400 + hour * 3_600 + minute * 60 + second - offset_minutes * 60; + Some(seconds * 1_000 + millis) +} + +const SECTION_ORDER: [Section; 5] = [ + Section::Pinned, + Section::Active, + Section::Working, + Section::Snoozed, + Section::Settled, +]; + +fn ms(text: Option<&str>) -> Option { + text.and_then(timestamp_ms) +} + +fn is_active(status: RunStatus) -> bool { + matches!( + status, + RunStatus::Preparing + | RunStatus::Queued + | RunStatus::Starting + | RunStatus::Running + | RunStatus::Waiting + ) +} + +/// The runtime status the web derives: the activity run's while one is open, else the thread's. +fn runtime_status(thread: &Thread) -> Option { + if thread.latest_run_id.is_none() && thread.active_provider_thread_id.is_none() { + return None; + } + Some(match thread.activity_run_status { + Some(activity) => match activity { + Activity::Preparing => RunStatus::Preparing, + Activity::Starting => RunStatus::Starting, + Activity::Running => RunStatus::Running, + Activity::Waiting => RunStatus::Waiting, + }, + None => thread.status, + }) +} + +fn pending_request(thread: &Thread) -> Option { + thread.pending_runtime_request.as_ref().map(|r| r.kind) +} + +/// `isThreadWorking` in `packages/client-runtime/src/state/threadInbox.ts`. +fn is_working(thread: &Thread) -> bool { + if pending_request(thread).is_some_and(|kind| kind != RequestKind::AuthRefresh) { + return false; + } + let status = runtime_status(thread); + if status.is_none_or(|s| !is_active(s) && s != RunStatus::Idle) { + return false; + } + let run_settled = thread.latest_run_id.is_some() + && !is_active(run_status(thread)) + && thread.active_run_id != thread.latest_run_id; + !(thread.interaction_mode == InteractionMode::Plan + && thread.has_actionable_proposed_plan + && run_settled) +} + +fn run_status(thread: &Thread) -> RunStatus { + match thread.status { + RunStatus::Idle => RunStatus::Completed, + other => other, + } +} + +fn run_completed_ms(thread: &Thread) -> Option { + thread.latest_run_id.as_ref()?; + match thread.latest_run_completed_at.as_deref() { + Some(at) => timestamp_ms(at), + None if thread.status == RunStatus::Idle || !is_active(thread.status) => { + timestamp_ms(&thread.updated_at) + } + None => None, + } +} + +/// `threadRaisedHandWhileSnoozed` without the subagent-request case, which needs other threads. +fn raised_hand(thread: &Thread) -> bool { + if pending_request(thread).is_some() || thread.pending_guest_message_count.unwrap_or(0) > 0 { + return true; + } + if runtime_status(thread) != Some(RunStatus::Failed) { + return false; + } + let failed_at = if thread.status == RunStatus::Failed { + run_completed_ms(thread) + } else { + timestamp_ms(&thread.updated_at) + }; + match ms(thread.snoozed_at.as_deref()) { + None => true, + Some(snoozed) => failed_at.is_some_and(|failed| failed > snoozed), + } +} + +/// `effectiveSnoozed` in `packages/client-runtime/src/state/threadSettled.ts`. +fn is_snoozed(thread: &Thread, now_ms: i64) -> bool { + ms(thread.snoozed_until.as_deref()).is_some_and(|wake| wake > now_ms) && !raised_hand(thread) +} + +/// Same precedence as `resolveSidebarThreadSection`, then the Working shelf split. +fn section_of(thread: &Thread, now_ms: i64) -> Section { + if is_snoozed(thread, now_ms) { + Section::Snoozed + } else if thread.settled_override == Some(Override::Settled) { + Section::Settled + } else if thread.pinned_at.is_some() { + Section::Pinned + } else if is_working(thread) { + Section::Working + } else { + Section::Active + } +} + +fn newest_first(a: (i64, &Thread), b: (i64, &Thread)) -> Ordering { + b.0.cmp(&a.0).then_with(|| a.1.id.cmp(&b.1.id)) +} + +fn created_ms(thread: &Thread) -> i64 { + timestamp_ms(&thread.created_at).unwrap_or(0) +} + +/// `sortPinnedThreadsByOrderKey`: keyed threads by key, then keyless newest created first. +fn sort_pinned(threads: &mut [&Thread]) { + threads.sort_by(|a, b| match (&a.pin_order_key, &b.pin_order_key) { + (Some(left), Some(right)) => left.cmp(right).then_with(|| a.id.cmp(&b.id)), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => newest_first((created_ms(a), a), (created_ms(b), b)), + }); +} + +/// `sortInboxThreadsByReturn` with no observed returns. +fn sort_inbox(threads: &mut [&Thread]) { + let anchor = |t: &Thread| { + [ + Some(created_ms(t)), + ms(t.unsettled_at.as_deref()), + t.latest_run_id + .as_ref() + .and(ms(t.latest_run_requested_at.as_deref())), + run_completed_ms(t), + ] + .into_iter() + .flatten() + .max() + .unwrap_or(0) + }; + threads.sort_by(|a, b| newest_first((anchor(a), a), (anchor(b), b))); +} + +/// `sortWorkingThreadsBySend`. +fn sort_working(threads: &mut [&Thread]) { + let anchor = |t: &Thread| { + let sent = t.latest_user_authored_message_at.as_deref().or_else(|| { + t.latest_run_id + .as_ref() + .and(t.latest_run_requested_at.as_deref()) + }); + created_ms(t).max(ms(sent).unwrap_or(0)) + }; + threads.sort_by(|a, b| newest_first((anchor(a), a), (anchor(b), b))); +} + +/// Oldest wake first. +fn sort_snoozed(threads: &mut [&Thread]) { + let wake = |t: &Thread| ms(t.snoozed_until.as_deref()).unwrap_or(0); + threads.sort_by(|a, b| wake(a).cmp(&wake(b)).then_with(|| a.id.cmp(&b.id))); +} + +/// `sortSettledThreads` with `resolveSettledThreadTimestamp`. +fn sort_settled(threads: &mut [&Thread]) { + let anchor = |t: &Thread| { + if let Some(settled) = ms(t.settled_at.as_deref()) { + return settled; + } + let latest = [ + ms(t.latest_user_message_at.as_deref()), + t.latest_run_id + .as_ref() + .and(ms(t.latest_run_requested_at.as_deref())), + t.latest_run_id + .as_ref() + .and(ms(t.latest_run_started_at.as_deref())), + run_completed_ms(t), + ] + .into_iter() + .flatten() + .max(); + latest.or_else(|| timestamp_ms(&t.updated_at)).unwrap_or(0) + }; + threads.sort_by(|a, b| newest_first((anchor(a), a), (anchor(b), b))); +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as i64) +} #[derive(Default)] pub struct ShellState { @@ -34,30 +307,44 @@ impl ShellState { self.threads.len() } - /// Threads newest first (ties by id); the first project is the oldest by creation. + /// Rows in the web sidebar's default order (see `Section`); the first project is the + /// oldest by creation. pub fn view(&self) -> ShellView { - let mut rows: Vec = self - .threads - .values() - .map(|thread| ThreadRow { - id: thread.id.clone(), - project_id: thread.project_id.clone(), - project: self - .projects - .get(&thread.project_id) - .map(|project| project.title.clone()) - .unwrap_or_default(), - title: thread.title.clone(), - branch: thread.branch.clone(), - status: thread.status, - updated_at: thread.updated_at.clone(), - }) + self.view_at(now_ms()) + } + + /// Mirrors `partitionSidebarThreads` in `apps/web/src/components/Sidebar.tsx` under the + /// default settings: Working shelf on, no project folders, nothing dragged. + pub fn view_at(&self, now_ms: i64) -> ShellView { + let mut sections: [Vec<&Thread>; 5] = Default::default(); + for thread in self.threads.values() { + sections[section_of(thread, now_ms) as usize].push(thread); + } + let [pinned, active, working, snoozed, settled] = &mut sections; + sort_pinned(pinned); + sort_inbox(active); + sort_working(working); + sort_snoozed(snoozed); + sort_settled(settled); + let row = |thread: &Thread, section| ThreadRow { + id: thread.id.clone(), + project_id: thread.project_id.clone(), + project: self + .projects + .get(&thread.project_id) + .map(|project| project.title.clone()) + .unwrap_or_default(), + title: thread.title.clone(), + branch: thread.branch.clone(), + status: thread.status, + section, + }; + let rows = SECTION_ORDER + .into_iter() + .zip(§ions) + .flat_map(|(section, threads)| threads.iter().map(move |thread| (section, *thread))) + .map(|(section, thread)| row(thread, section)) .collect(); - rows.sort_by(|a, b| { - b.updated_at - .cmp(&a.updated_at) - .then_with(|| a.id.cmp(&b.id)) - }); ShellView { projects: self.project_count(), threads: self.thread_count(), @@ -210,12 +497,288 @@ mod tests { assert_eq!(state.project_count(), 0); } + const NOW: i64 = 1_800_000_000_000; + + fn thread(id: &str, edit: impl FnOnce(&mut Value)) -> Thread { + let mut value: Value = serde_json::from_str(THREAD_UPDATED).expect("json"); + let mut thread = value["thread"].take(); + thread["id"] = id.into(); + edit(&mut thread); + serde_json::from_value(thread).expect("thread decodes") + } + + fn view_of(threads: Vec) -> ShellView { + let mut state = seeded(); + state.threads = threads.into_iter().map(|t| (t.id.clone(), t)).collect(); + state.view_at(NOW) + } + + fn ids(view: &ShellView) -> Vec<&str> { + view.rows.iter().map(|row| row.id.as_str()).collect() + } + + fn set(thread: &mut Value, fields: &[(&str, Value)]) { + for (key, value) in fields { + thread[*key] = value.clone(); + } + } + + #[test] + fn timestamps_parse_like_date_parse() { + for (text, expected) in [ + ("1970-01-01T00:00:00.000Z", Some(0)), + ("2000-03-01T00:00:00Z", Some(951_868_800_000)), + ("2026-07-01T00:00:00.123Z", Some(1_782_864_000_123)), + ("2026-07-01T02:00:00.5+02:00", Some(1_782_864_000_500)), + ("2026-07-01", None), + ("nope", None), + ] { + assert_eq!(timestamp_ms(text), expected, "{text}"); + } + } + + #[test] + fn an_updated_at_bump_does_not_reorder_active_threads() { + let old = |bump: &str| { + thread("old", |t| { + set( + t, + &[ + ("createdAt", "2026-01-01T00:00:00.000Z".into()), + ("updatedAt", bump.into()), + ], + ) + }) + }; + let new = thread("new", |t| { + set(t, &[("createdAt", "2026-02-01T00:00:00.000Z".into())]) + }); + for bump in ["2026-01-01T00:00:00.000Z", "2030-12-01T00:00:00.000Z"] { + let view = view_of(vec![old(bump), new.clone()]); + assert_eq!(ids(&view), ["new", "old"], "updatedAt {bump}"); + } + } + + #[test] + fn active_threads_sort_by_latest_anchor_and_ignore_the_order_key() { + let at = |id: &str, fields: Vec<(&str, Value)>| thread(id, |t| set(t, &fields)); + let created = |m: &str| Value::from(format!("2026-{m}-01T00:00:00.000Z")); + let view = view_of(vec![ + at("a-created", vec![("createdAt", created("01"))]), + at( + "b-unsettled", + vec![("createdAt", created("01")), ("unsettledAt", created("05"))], + ), + at( + "c-keyed", + vec![("createdAt", created("02")), ("activeOrderKey", "a".into())], + ), + at( + "d-run", + vec![ + ("createdAt", created("01")), + ("latestRunId", "run".into()), + ("status", "completed".into()), + ("latestRunRequestedAt", created("03")), + ("latestRunCompletedAt", created("04")), + ], + ), + at("e-tie", vec![("createdAt", created("01"))]), + ]); + assert_eq!( + ids(&view), + ["b-unsettled", "d-run", "c-keyed", "a-created", "e-tie"] + ); + } + + #[test] + fn pinned_threads_sort_keyed_first_then_newest_created() { + let pinned = |id: &str, key: Option<&str>, created: &str| { + thread(id, |t| { + set( + t, + &[ + ("pinnedAt", "2026-03-01T00:00:00.000Z".into()), + ("createdAt", created.into()), + ], + ); + if let Some(key) = key { + t["pinOrderKey"] = key.into(); + } + }) + }; + let view = view_of(vec![ + pinned("keyless-old", None, "2026-01-01T00:00:00.000Z"), + pinned("keyed-b", Some("b"), "2026-01-01T00:00:00.000Z"), + pinned("keyless-new", None, "2026-02-01T00:00:00.000Z"), + pinned("keyed-a", Some("a"), "2026-02-01T00:00:00.000Z"), + ]); + assert_eq!( + ids(&view), + ["keyed-a", "keyed-b", "keyless-new", "keyless-old"] + ); + assert!(view.rows.iter().all(|r| r.section == Section::Pinned)); + } + + #[test] + fn settled_threads_sort_by_settled_at_then_the_fallback_chain() { + let settled = |id: &str, fields: Vec<(&str, Value)>| { + thread(id, |t| { + set(t, &[("settledOverride", "settled".into())]); + set(t, &fields); + }) + }; + let day = |d: u32| Value::from(format!("2026-05-{d:02}T00:00:00.000Z")); + let view = view_of(vec![ + settled("settled-at", vec![("settledAt", day(20))]), + settled("user-message", vec![("latestUserMessageAt", day(15))]), + settled( + "run-started", + vec![ + ("latestRunId", "r".into()), + ("status", "completed".into()), + ("latestRunStartedAt", day(18)), + ("latestRunCompletedAt", day(10)), + ], + ), + settled("updated", vec![("updatedAt", day(12))]), + settled("unparsable", vec![("updatedAt", "garbage".into())]), + ]); + assert_eq!( + ids(&view), + [ + "settled-at", + "run-started", + "user-message", + "updated", + "unparsable" + ] + ); + assert!(view.rows.iter().all(|r| r.section == Section::Settled)); + } + + #[test] + fn sections_follow_snooze_then_settled_then_pinned_then_working() { + let future = "2030-12-01T00:00:00.000Z"; + let running = |t: &mut Value| { + set( + t, + &[ + ("latestRunId", "run".into()), + ("activeRunId", "run".into()), + ("status", "running".into()), + ], + ) + }; + let view = view_of(vec![ + thread("idle", |_| {}), + thread("working", running), + thread("pinned-working", |t| { + running(t); + t["pinnedAt"] = "2026-03-01T00:00:00.000Z".into(); + }), + thread("settled-pinned", |t| { + set( + t, + &[ + ("settledOverride", "settled".into()), + ("pinnedAt", "2026-03-01T00:00:00.000Z".into()), + ], + ) + }), + thread("snoozed", |t| { + set( + t, + &[ + ("snoozedUntil", future.into()), + ("snoozedAt", "2026-03-01T00:00:00.000Z".into()), + ], + ) + }), + thread("woken", |t| { + t["snoozedUntil"] = "2026-01-01T00:00:00.000Z".into() + }), + thread("snoozed-with-approval", |t| { + set( + t, + &[ + ("snoozedUntil", future.into()), + ( + "pendingRuntimeRequest", + serde_json::json!({"id": "q", "kind": "command", "createdAt": "2026-03-01T00:00:00.000Z"}), + ), + ], + ) + }), + thread("plan-ready", |t| { + set( + t, + &[ + ("interactionMode", "plan".into()), + ("hasActionableProposedPlan", true.into()), + ("latestRunId", "run".into()), + ("activeRunId", Value::Null), + ("status", "completed".into()), + ], + ) + }), + ]); + let sections: HashMap<_, _> = view + .rows + .iter() + .map(|row| (row.id.as_str(), row.section)) + .collect(); + assert_eq!( + sections, + HashMap::from([ + ("idle", Section::Active), + ("working", Section::Working), + ("pinned-working", Section::Pinned), + ("settled-pinned", Section::Settled), + ("snoozed", Section::Snoozed), + ("woken", Section::Active), + ("snoozed-with-approval", Section::Active), + ("plan-ready", Section::Active), + ]) + ); + let order: Vec<_> = view.rows.iter().map(|r| r.section).collect(); + assert!(order.is_sorted_by_key(|s| *s as u8)); + } + + #[test] + fn working_threads_sort_by_latest_send_and_snoozed_by_wake() { + let running = |id: &str, sent: &str| { + thread(id, |t| { + set( + t, + &[ + ("latestRunId", "run".into()), + ("activeRunId", "run".into()), + ("status", "running".into()), + ("latestUserAuthoredMessageAt", sent.into()), + ], + ) + }) + }; + let snoozed = |id: &str, until: &str| thread(id, |t| t["snoozedUntil"] = until.into()); + let view = view_of(vec![ + running("sent-early", "2026-08-01T00:00:00.000Z"), + running("sent-late", "2026-09-01T00:00:00.000Z"), + snoozed("wakes-late", "2030-02-01T00:00:00.000Z"), + snoozed("wakes-soon", "2030-01-01T00:00:00.000Z"), + ]); + assert_eq!( + ids(&view), + ["sent-late", "sent-early", "wakes-soon", "wakes-late"] + ); + } + #[test] - fn view_lists_newest_threads_first_with_project_names() { + fn view_names_projects_and_counts_threads() { let mut state = seeded(); state.apply(with(THREAD_UPDATED, |value| { value["thread"]["id"] = "thread-new".into(); - value["thread"]["updatedAt"] = "2026-07-01T00:00:00.000Z".into(); + value["thread"]["createdAt"] = "2026-07-01T00:00:00.000Z".into(); })); let view = state.view(); assert_eq!((view.projects, view.threads), (1, 2)); @@ -225,8 +788,6 @@ mod tests { .map(|e| e.title.as_str()) .collect(); assert_eq!(titles, ["Project"]); - let ids: Vec<_> = view.rows.iter().map(|row| row.id.as_str()).collect(); - assert_eq!(ids, ["thread-new", "thread-v2"]); assert!(view.rows.iter().all(|row| row.project == "Project")); } diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index f1559520ad80..4199193a84f6 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -20,7 +20,18 @@ pub enum Status { Environment(EnvironmentProjects), } -/// What the window needs from the shell state, newest thread first. +/// Where the web sidebar lists a thread. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Section { + Pinned, + Active, + Working, + Snoozed, + Settled, +} + +/// What the window needs from the shell state, `rows` in sidebar order: sections in +/// `Section` order, each sorted as the web sorts it. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ShellView { pub projects: usize, @@ -38,7 +49,7 @@ pub struct ThreadRow { pub title: String, pub branch: Option, pub status: ThreadsEntryStatus, - pub updated_at: String, + pub section: Section, } fn plural(count: usize, noun: &str) -> String { diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 26b4652087e8..735624efe285 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -1,6 +1,6 @@ use crate::connection::{Action, ActionSender}; use crate::projects::{self, EnvironmentProjects, MachineKind, ProjectEntry}; -use crate::status::{ShellView, Status, ThreadRow}; +use crate::status::{Section, ShellView, Status, ThreadRow}; use crate::wire::generated::ThreadsEntryStatus as ThreadStatus; use gpui::{ Anchor, AnyElement, App, Context, Entity, Focusable as _, FontWeight, Hsla, IntoElement, @@ -29,6 +29,8 @@ pub struct MainView { project_search: Entity, composer: Entity, list: UniformListScrollHandle, + /// Working, Snoozed and Settled shelves; the web starts all of them collapsed. + shelves: [bool; 3], selected_project: Option, picker_open: bool, picker_order: Option>, @@ -62,6 +64,7 @@ impl MainView { .auto_grow(3, 10) }), list: UniformListScrollHandle::new(), + shelves: [false; 3], selected_project: None, picker_open: false, picker_order: None, @@ -116,7 +119,9 @@ impl MainView { } fn sidebar(&self, cx: &mut Context) -> impl IntoElement { - let count = self.shell().map_or(0, |shell| shell.rows.len()); + let count = self + .shell() + .map_or(0, |shell| sidebar_items(&shell.rows, self.shelves).len()); v_flex() .w(px(SIDEBAR_WIDTH)) .flex_shrink_0() @@ -136,9 +141,27 @@ impl MainView { return Vec::new(); }; let (muted, hover) = (cx.theme().muted_foreground, cx.theme().list_hover); - shell.rows[range] + let items = sidebar_items(&shell.rows, this.shelves); + items[range] .iter() - .map(|row| thread_row(row, muted, hover)) + .map(|item| match *item { + SidebarItem::Row(index) => { + thread_row(&shell.rows[index], muted, hover).into_any_element() + } + SidebarItem::Header { + section, + count, + expanded, + } => { + let shelf = shelf_index(section); + shelf_header(section, count, expanded, muted, hover) + .on_click(cx.listener(move |this, _, _, cx| { + this.shelves[shelf] = !this.shelves[shelf]; + cx.notify(); + })) + .into_any_element() + } + }) .collect() }), ) @@ -565,6 +588,92 @@ fn status_tag(status: ThreadStatus) -> Option { Some(tag.small().child(label)) } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SidebarItem { + Row(usize), + Header { + section: Section, + count: usize, + expanded: bool, + }, +} + +fn shelf_index(section: Section) -> usize { + match section { + Section::Working => 0, + Section::Snoozed => 1, + _ => 2, + } +} + +/// The web's default list: pinned and active rows, then a header per shelf (Working and +/// Snoozed only when non-empty, Settled always) with its rows only while expanded. +fn sidebar_items(rows: &[ThreadRow], expanded: [bool; 3]) -> Vec { + if rows.is_empty() { + return Vec::new(); + } + let mut items = Vec::with_capacity(rows.len() + 3); + for (index, row) in rows.iter().enumerate() { + if matches!(row.section, Section::Pinned | Section::Active) { + items.push(SidebarItem::Row(index)); + } + } + for section in [Section::Working, Section::Snoozed, Section::Settled] { + let count = rows.iter().filter(|row| row.section == section).count(); + if count == 0 && section != Section::Settled { + continue; + } + let expanded = expanded[shelf_index(section)]; + items.push(SidebarItem::Header { + section, + count, + expanded, + }); + if expanded { + let shelf = rows + .iter() + .enumerate() + .filter(|(_, row)| row.section == section); + items.extend(shelf.map(|(index, _)| SidebarItem::Row(index))); + } + } + items +} + +fn shelf_header( + section: Section, + count: usize, + expanded: bool, + muted: Hsla, + hover: Hsla, +) -> gpui::Stateful { + let label = match section { + Section::Working => "Working", + Section::Snoozed => "Snoozed", + _ => "Settled", + }; + let chevron = if expanded { + IconName::ChevronDown + } else { + IconName::ChevronRight + }; + h_flex() + .id(label) + .h(px(ROW_HEIGHT)) + .w_full() + .px_2() + .gap_2() + .items_end() + .pb_2() + .rounded_md() + .text_xs() + .text_color(muted) + .hover(|style| style.bg(hover)) + .child(Icon::new(chevron).small()) + .child(div().flex_1().child(label)) + .child(count.to_string()) +} + fn thread_row(row: &ThreadRow, muted: Hsla, hover: Hsla) -> impl IntoElement + use<> { v_flex() .id(row.id.clone()) @@ -657,10 +766,47 @@ mod tests { title: String::new(), branch: None, status: ThreadStatus::Idle, - updated_at: String::new(), + section: Section::Active, } } + #[test] + fn sidebar_items_keep_shelves_collapsed_until_expanded() { + let in_section = |id: &str, section| ThreadRow { section, ..row(id) }; + let rows = vec![ + in_section("p", Section::Pinned), + in_section("a", Section::Active), + in_section("w", Section::Working), + in_section("s", Section::Settled), + ]; + let header = |section, count, expanded| SidebarItem::Header { + section, + count, + expanded, + }; + assert_eq!( + sidebar_items(&rows, [false; 3]), + [ + SidebarItem::Row(0), + SidebarItem::Row(1), + header(Section::Working, 1, false), + header(Section::Settled, 1, false), + ] + ); + assert_eq!( + sidebar_items(&rows, [true, false, true]), + [ + SidebarItem::Row(0), + SidebarItem::Row(1), + header(Section::Working, 1, true), + SidebarItem::Row(2), + header(Section::Settled, 1, true), + SidebarItem::Row(3), + ] + ); + assert!(sidebar_items(&[], [true; 3]).is_empty()); + } + #[test] fn active_project_prefers_a_live_pick_then_the_newest_real_project() { let shell = ShellView { From ab7f56ba686a3318e1379fa3ecb9f99ff8b31415 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 5 Nov 2026 02:43:44 -0500 Subject: [PATCH 11/82] fix(desktop-gpui): tooltips and the scratch link match the web Tooltips used gpui-component's default text-sm and a key chip, and the "or start without a project" link stayed muted on hover. Tooltips now use the web TooltipPopup sizing with the shortcut as plain text, and the link is medium weight and turns foreground with an underline on hover, like the web's muted InlineButton. Signed-off-by: Yordis Prieto --- apps/desktop-gpui/src/ui.rs | 56 ++++++++++++++++++++++++------------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 735624efe285..8d1de4e01c66 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -220,6 +220,7 @@ impl MainView { cx: &mut Context, ) -> impl IntoElement { let view = cx.entity(); + let foreground = cx.theme().foreground; let (actions, can_pin) = (self.actions.clone(), self.can_pin()); let order = self.picker_order.clone(); let search = self.project_search.clone(); @@ -352,15 +353,13 @@ impl MainView { div() .id("start-without-project") .text_sm() + .font_weight(FontWeight::MEDIUM) .text_color(cx.theme().muted_foreground) .cursor_pointer() - .hover(|style| style.underline()) - .tooltip(|window, cx| { - Tooltip::element(|_, _| { - Kbd::new(Keystroke::parse("alt-cmd-n").expect("valid keystroke")) - }) - .build(window, cx) - }) + .hover(|style| style.underline().text_color(foreground)) + .tooltip(tooltip(Kbd::format( + &Keystroke::parse("alt-cmd-n").expect("valid keystroke"), + ))) .child("or start without a project"), ) } @@ -384,12 +383,16 @@ impl MainView { h_flex() .justify_between() .child( - Button::new("attach") - .ghost() - .small() - .icon(gpui_kit_assets::IconName::Paperclip) - .tooltip("Attach files") - .on_click(|_, _, _| {}), + div() + .id("attach-tooltip") + .tooltip(tooltip("Attach files")) + .child( + Button::new("attach") + .ghost() + .small() + .icon(gpui_kit_assets::IconName::Paperclip) + .on_click(|_, _, _| {}), + ), ) .child( Button::new("send") @@ -439,7 +442,7 @@ impl Selectable for ProjectTrigger { impl RenderOnce for ProjectTrigger { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { - let tooltip = self.name.clone(); + let name = self.name.clone(); div() .id("project") .ml_2() @@ -450,11 +453,25 @@ impl RenderOnce for ProjectTrigger { .cursor_pointer() .when(self.open, |name| name.underline()) .hover(|style| style.underline()) - .tooltip(move |window, cx| Tooltip::new(tooltip.clone()).build(window, cx)) + .tooltip(tooltip(name)) .child(self.name) } } +/// The web's `TooltipPopup` sizing: `text-xs`, `px-2 py-1`, `rounded-md`. +fn tooltip( + text: impl Into, +) -> impl Fn(&mut Window, &mut App) -> gpui::AnyView + 'static { + let text = text.into(); + move |window, cx| { + Tooltip::new(text.clone()) + .text_xs() + .py_1() + .rounded(px(6.)) + .build(window, cx) + } +} + fn machine_icon(kind: MachineKind) -> gpui_kit_assets::IconName { use gpui_kit_assets::IconName as Icons; match kind { @@ -480,7 +497,7 @@ fn environment_badge( div() .id(("environment", index)) .flex_shrink_0() - .tooltip(move |window, cx| Tooltip::new(text.clone()).build(window, cx)) + .tooltip(tooltip(text)) .child(Icon::new(machine_icon(machine)).small().text_color(muted)), ) } @@ -497,10 +514,10 @@ fn pin_button( ) -> impl IntoElement + use<> { let pinned = entry.pinned; let icon = |name| Icon::new(name).small(); - Button::new(("pin", index)) + let hint = tooltip(projects::pin_tooltip(&entry.title, pinned)); + let button = Button::new(("pin", index)) .ghost() .xsmall() - .tooltip(projects::pin_tooltip(&entry.title, pinned)) .when(!pinned, |button| { button .invisible() @@ -535,7 +552,8 @@ fn pin_button( eprintln!("[ui] could not queue the pin request"); } } - }) + }); + div().id(("pin-tooltip", index)).tooltip(hint).child(button) } /// The picked project id while its row still exists, else the active row's target. From 25b500d6898c7d81357aed2e93b5e7528270b2ee Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 5 Nov 2026 02:55:44 -0500 Subject: [PATCH 12/82] feat(desktop-gpui): start without a project, with the web's picker popup Signed-off-by: Yordis Prieto --- apps/desktop-gpui/src/assets.rs | 27 ++++ apps/desktop-gpui/src/projects.rs | 4 +- apps/desktop-gpui/src/ui.rs | 232 ++++++++++++++++++++++++------ 3 files changed, 214 insertions(+), 49 deletions(-) diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index dc388b45670d..a931d406b08c 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -9,10 +9,27 @@ gpui_kit_assets::icon_assets!( ] ); +pub const MESSAGE_SQUARE_DASHED: &str = "icons/message-square-dashed.svg"; + +/// lucide-react 0.564.0 `message-square-dashed`, the icon the server gives the no-project workspace. +const MESSAGE_SQUARE_DASHED_SVG: &str = concat!( + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, + r#""#, +); + pub struct AppAssets; impl AssetSource for AppAssets { fn load(&self, path: &str) -> Result>> { + if path == MESSAGE_SQUARE_DASHED { + return Ok(Some(Cow::Borrowed(MESSAGE_SQUARE_DASHED_SVG.as_bytes()))); + } match ExtraIcons.load(path)? { Some(bytes) => Ok(Some(bytes)), None => gpui_kit_assets::Assets.load(path), @@ -21,6 +38,9 @@ impl AssetSource for AppAssets { fn list(&self, path: &str) -> Result> { let mut paths = gpui_kit_assets::Assets.list(path)?; + if MESSAGE_SQUARE_DASHED.starts_with(path) { + paths.push(MESSAGE_SQUARE_DASHED.into()); + } paths.extend(ExtraIcons.list(path)?); Ok(paths) } @@ -49,5 +69,12 @@ mod tests { let loaded = AppAssets.load(&icon.path()).ok().flatten(); assert!(loaded.is_some(), "{} is not embedded", icon.path()); } + assert!( + AppAssets + .load(MESSAGE_SQUARE_DASHED) + .ok() + .flatten() + .is_some() + ); } } diff --git a/apps/desktop-gpui/src/projects.rs b/apps/desktop-gpui/src/projects.rs index 9e834311932a..4d502fd40305 100644 --- a/apps/desktop-gpui/src/projects.rs +++ b/apps/desktop-gpui/src/projects.rs @@ -110,9 +110,9 @@ impl ProjectEntry { active_id.filter(|id| self.contains(id)).unwrap_or(&self.id) } - /// The badge text color for the theme; the badge background is this at 14% alpha. + /// The badge text color for the theme (gray for the scratch project, whose server-set icon is gray); the badge background is this at 14% alpha. pub fn color_hex(&self, dark: bool) -> u32 { - let (_, light, night) = COLORS[self.color]; + let (_, light, night) = COLORS[if self.scratch { 0 } else { self.color }]; if dark { night } else { light } } } diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 8d1de4e01c66..cbf9a9e5c9f9 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -1,3 +1,4 @@ +use crate::assets::MESSAGE_SQUARE_DASHED; use crate::connection::{Action, ActionSender}; use crate::projects::{self, EnvironmentProjects, MachineKind, ProjectEntry}; use crate::status::{Section, ShellView, Status, ThreadRow}; @@ -9,7 +10,6 @@ use gpui::{ }; use gpui_component::{ ActiveTheme, Icon, IconName, Selectable, Sizable, TitleBar, - breadcrumb::{Breadcrumb, BreadcrumbItem}, button::{Button, ButtonVariants}, h_flex, input::{Input, InputEvent, InputState, Textarea, TextareaState}, @@ -22,6 +22,8 @@ use gpui_component::{ const SIDEBAR_WIDTH: f32 = 300.; const ROW_HEIGHT: f32 = 68.; +const NO_PROJECT: &str = "No project"; +const HEADLINE_SCRATCH: &str = "What should we work on?"; pub struct MainView { status: Status, @@ -179,6 +181,7 @@ impl MainView { .into_any_element(); }; let active = active_project(self.selected_project.as_deref(), shell); + let scratch = active.is_some_and(|entry| entry.scratch); let project = active.map_or_else(|| "your project".to_owned(), |entry| entry.title.clone()); v_flex() .flex_1() @@ -192,17 +195,14 @@ impl MainView { .items_center() .border_b_1() .border_color(cx.theme().border) - .child( - Breadcrumb::new() - .child(BreadcrumbItem::new(project.clone())) - .child(BreadcrumbItem::new("New thread")), - ), + .child(self.breadcrumb(active, cx)), ) .child( centered(cx) .gap_6() .child(self.hero( &project, + scratch, selected_id(self, active).map(str::to_owned), projects::with_remote_members(&shell.project_entries, &self.environments), cx, @@ -212,16 +212,82 @@ impl MainView { .into_any_element() } + fn breadcrumb( + &self, + active: Option<&ProjectEntry>, + cx: &mut Context, + ) -> impl IntoElement { + let theme = cx.theme(); + let (foreground, muted, dark) = (theme.foreground, theme.muted_foreground, theme.is_dark()); + let mono = theme.mono_font_family.clone(); + let project = active.map(|entry| { + let color: Hsla = rgb(entry.color_hex(dark)).into(); + let icon = if entry.scratch { + Icon::empty() + .path(MESSAGE_SQUARE_DASHED) + .size(px(14.)) + .text_color(color) + .into_any_element() + } else { + div() + .flex() + .flex_shrink_0() + .items_center() + .justify_center() + .size(px(14.)) + .rounded(px(3.5)) + .bg(color.opacity(0.14)) + .text_color(color) + .text_size(px(7.21875)) + .font_weight(FontWeight::BOLD) + .font_family(mono) + .child(entry.initials.clone()) + .into_any_element() + }; + h_flex() + .gap_3() + .child( + h_flex() + .id("breadcrumb-project") + .gap(px(6.)) + .min_w_0() + .text_color(muted) + .hover(move |style| style.text_color(foreground)) + .child(icon) + .child( + div() + .max_w(px(160.)) + .truncate() + .child(project_label(Some(entry))), + ), + ) + .child(div().flex_shrink_0().text_color(muted).child("/")) + }); + h_flex() + .gap_3() + .items_center() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .children(project) + .child(div().text_color(foreground).child("New thread")) + } + fn hero( &self, project: &str, + scratch: bool, active_id: Option, entries: Vec, cx: &mut Context, ) -> impl IntoElement { let view = cx.entity(); + let link_view = view.clone(); let foreground = cx.theme().foreground; let (actions, can_pin) = (self.actions.clone(), self.can_pin()); + let scratch_id = self + .shell() + .and_then(|shell| scratch_entry(&shell.project_entries)) + .map(|entry| entry.id.clone()); let order = self.picker_order.clone(); let search = self.project_search.clone(); let picker = Popover::new("project-picker") @@ -240,9 +306,10 @@ impl MainView { }); } }) - .trigger(ProjectTrigger::new(project)) - .content(move |_, _, cx| { + .trigger(ProjectTrigger::new(project, scratch)) + .content(move |_, window, cx| { let query = search.read(cx).value(); + let searching = search.focus_handle(cx).is_focused(window); let mut listed = projects::picker_entries(&entries, active_id.as_deref(), &query); if let Some(order) = &order { listed = projects::order_while_open(listed, order); @@ -251,7 +318,7 @@ impl MainView { let (dark, muted) = (theme.is_dark(), theme.muted_foreground); let (hover, selected) = (theme.list_hover, theme.list_active); let mono = theme.mono_font_family.clone(); - let border = theme.border; + let (border, ring) = (theme.border, theme.ring); let rows = listed.iter().enumerate().map(|(index, entry)| { let (view, id) = (view.clone(), entry.target(active_id.as_deref()).to_owned()); let (actions, pin_ids) = (actions.clone(), entry.member_ids.clone()); @@ -311,19 +378,34 @@ impl MainView { }); }) }); + // The web's `ComboboxPopup` (`w-72`, at most 23rem tall) and `ComboboxSearchInput`. v_flex() - .w(px(400.)) + .w(px(288.)) + .rounded_lg() + .overflow_hidden() .child( - h_flex().px_3().border_b_1().border_color(border).child( - Input::new(&search) - .appearance(false) - .prefix(Icon::new(IconName::Search)), + div().px_3().pt(px(10.)).child( + h_flex() + .pb(px(6.)) + .border_b_1() + .border_color(if searching { ring } else { border.opacity(0.7) }) + .child( + Input::new(&search) + .appearance(false) + .h(px(26.)) + .px_0() + .prefix( + Icon::new(IconName::Search) + .size_4() + .text_color(muted.opacity(0.55)), + ), + ), ), ) .child( v_flex() .id("project-list") - .max_h(px(320.)) + .max_h(px(325.)) .overflow_y_scroll() .p_1() .children(rows) @@ -338,30 +420,52 @@ impl MainView { }), ) }); - v_flex() - .items_center() - .gap_1() - .child( - h_flex() - .items_center() - .text_2xl() - .child("What should we build in") - .child(picker) - .child("?"), - ) - .child( - div() - .id("start-without-project") - .text_sm() - .font_weight(FontWeight::MEDIUM) - .text_color(cx.theme().muted_foreground) - .cursor_pointer() - .hover(|style| style.underline().text_color(foreground)) - .tooltip(tooltip(Kbd::format( - &Keystroke::parse("alt-cmd-n").expect("valid keystroke"), - ))) - .child("or start without a project"), - ) + let (heading, below) = if scratch { + let heading = h_flex() + .items_center() + .text_3xl() + .text_color(foreground) + .child(HEADLINE_SCRATCH) + .into_any_element(); + (heading, picker.into_any_element()) + } else { + let heading = h_flex() + .items_center() + .text_2xl() + .child("What should we build in") + .child(picker) + .child("?") + .into_any_element(); + let link = div() + .id("start-without-project") + .font_weight(FontWeight::MEDIUM) + .text_color(cx.theme().muted_foreground) + .cursor_pointer() + .hover(|style| style.underline().text_color(foreground)) + .tooltip(tooltip(Kbd::format( + &Keystroke::parse("alt-cmd-n").expect("valid keystroke"), + ))) + .on_click(move |_, _, cx| { + let Some(id) = scratch_id.clone() else { return }; + link_view.update(cx, |this, cx| { + this.selected_project = Some(id); + this.picker_open = false; + cx.notify(); + }); + }) + .child("or start without a project") + .into_any_element(); + (heading, link) + }; + v_flex().items_center().child(heading).child( + h_flex() + .mt_2() + .h_6() + .items_center() + .justify_center() + .text_sm() + .child(below), + ) } fn composer_box(&self, cx: &mut Context) -> impl IntoElement { @@ -406,16 +510,19 @@ impl MainView { } /// The project name as plain heading text that underlines on hover and while its picker is open. +/// Under a scratch draft it is the `InlineButton` `picker` tone at `text-sm`, `font-medium`. #[derive(IntoElement)] struct ProjectTrigger { name: String, + scratch: bool, open: bool, } impl ProjectTrigger { - fn new(name: &str) -> Self { + fn new(name: &str, scratch: bool) -> Self { Self { - name: name.to_owned(), + name: if scratch { NO_PROJECT } else { name }.to_owned(), + scratch, open: false, } } @@ -443,21 +550,38 @@ impl Selectable for ProjectTrigger { impl RenderOnce for ProjectTrigger { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { let name = self.name.clone(); - div() + let trigger = div() .id("project") - .ml_2() .min_w_0() .max_w_full() .truncate() - .text_2xl() .cursor_pointer() .when(self.open, |name| name.underline()) - .hover(|style| style.underline()) - .tooltip(tooltip(name)) - .child(self.name) + .hover(|style| style.underline()); + if self.scratch { + trigger.font_weight(FontWeight::MEDIUM).child(self.name) + } else { + trigger + .ml_2() + .text_2xl() + .tooltip(tooltip(name)) + .child(self.name) + } } } +fn project_label(entry: Option<&ProjectEntry>) -> String { + match entry { + Some(entry) if entry.scratch => NO_PROJECT.to_owned(), + Some(entry) => entry.title.clone(), + None => String::new(), + } +} + +fn scratch_entry(entries: &[ProjectEntry]) -> Option<&ProjectEntry> { + entries.iter().find(|entry| entry.scratch) +} + /// The web's `TooltipPopup` sizing: `text-xs`, `px-2 py-1`, `rounded-md`. fn tooltip( text: impl Into, @@ -825,6 +949,20 @@ mod tests { assert!(sidebar_items(&[], [true; 3]).is_empty()); } + #[test] + fn scratch_reads_as_no_project_and_is_found_in_the_entries() { + let mut none = entry("none", true); + none.title = "Whatever the server titled it".to_owned(); + assert_eq!(project_label(Some(&none)), "No project"); + assert_eq!(project_label(Some(&entry("alpha", false))), "alpha"); + assert_eq!(project_label(None), ""); + let entries = [entry("alpha", false), none]; + assert_eq!(scratch_entry(&entries).map(|e| e.id.as_str()), Some("none")); + assert!(scratch_entry(&entries[..1]).is_none()); + assert_eq!(ProjectTrigger::new("alpha", true).name, "No project"); + assert_eq!(ProjectTrigger::new("alpha", false).name, "alpha"); + } + #[test] fn active_project_prefers_a_live_pick_then_the_newest_real_project() { let shell = ShellView { From b6535f3b5b070cebe5a4914ce9c86d5460d053cb Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Thu, 5 Nov 2026 03:00:03 -0500 Subject: [PATCH 13/82] fix(desktop-gpui): project picker popup stays below its trigger Signed-off-by: Yordis Prieto --- apps/desktop-gpui/src/ui.rs | 50 +++++++++++++++++++++++++++++++------ 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index cbf9a9e5c9f9..1822c899b501 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -4,9 +4,9 @@ use crate::projects::{self, EnvironmentProjects, MachineKind, ProjectEntry}; use crate::status::{Section, ShellView, Status, ThreadRow}; use crate::wire::generated::ThreadsEntryStatus as ThreadStatus; use gpui::{ - Anchor, AnyElement, App, Context, Entity, Focusable as _, FontWeight, Hsla, IntoElement, - Keystroke, ParentElement, Render, RenderOnce, Styled, UniformListScrollHandle, Window, div, - prelude::*, px, rgb, uniform_list, + Anchor, AnyElement, App, Bounds, Context, Entity, Focusable as _, FontWeight, Hsla, + IntoElement, Keystroke, ParentElement, Pixels, Render, RenderOnce, Styled, + UniformListScrollHandle, Window, canvas, div, prelude::*, px, rgb, uniform_list, }; use gpui_component::{ ActiveTheme, Icon, IconName, Selectable, Sizable, TitleBar, @@ -19,11 +19,18 @@ use gpui_component::{ tooltip::Tooltip, v_flex, }; +use std::{cell::Cell, rc::Rc}; const SIDEBAR_WIDTH: f32 = 300.; const ROW_HEIGHT: f32 = 68.; const NO_PROJECT: &str = "No project"; const HEADLINE_SCRATCH: &str = "What should we work on?"; +/// The web's `ComboboxPopup` cap, `max-h-[min(var(--available-height),23rem)]`. +const PICKER_MAX_HEIGHT: f32 = 368.; +/// `ComboboxSearchInput`: `pt-2.5`, a 26px input, `pb-1.5` and its 1px border. +const PICKER_SEARCH_HEIGHT: f32 = 43.; +/// The popover's 4px offset from the trigger plus the positioner's 4px viewport margin. +const PICKER_GAP: f32 = 8.; pub struct MainView { status: Status, @@ -35,6 +42,8 @@ pub struct MainView { shelves: [bool; 3], selected_project: Option, picker_open: bool, + /// Last painted trigger bounds, so the popup can shrink to fit below it as on the web. + picker_trigger: Rc>>, picker_order: Option>, /// The other saved environments, by id. environments: Vec, @@ -69,6 +78,7 @@ impl MainView { shelves: [false; 3], selected_project: None, picker_open: false, + picker_trigger: Rc::default(), picker_order: None, environments: Vec::new(), actions, @@ -290,6 +300,7 @@ impl MainView { .map(|entry| entry.id.clone()); let order = self.picker_order.clone(); let search = self.project_search.clone(); + let trigger_bounds = self.picker_trigger.clone(); let picker = Popover::new("project-picker") .anchor(Anchor::TopCenter) .open(self.picker_open) @@ -306,10 +317,18 @@ impl MainView { }); } }) - .trigger(ProjectTrigger::new(project, scratch)) + .trigger(ProjectTrigger::new( + project, + scratch, + trigger_bounds.clone(), + )) .content(move |_, window, cx| { let query = search.read(cx).value(); let searching = search.focus_handle(cx).is_focused(window); + // Shrinking keeps the positioner from flipping the popup over the headline. + let below = window.viewport_size().height - trigger_bounds.get().bottom(); + let list_height = + (below - px(PICKER_GAP)).min(px(PICKER_MAX_HEIGHT)) - px(PICKER_SEARCH_HEIGHT); let mut listed = projects::picker_entries(&entries, active_id.as_deref(), &query); if let Some(order) = &order { listed = projects::order_while_open(listed, order); @@ -405,7 +424,7 @@ impl MainView { .child( v_flex() .id("project-list") - .max_h(px(325.)) + .max_h(list_height.max(px(0.))) .overflow_y_scroll() .p_1() .children(rows) @@ -516,14 +535,16 @@ struct ProjectTrigger { name: String, scratch: bool, open: bool, + bounds: Rc>>, } impl ProjectTrigger { - fn new(name: &str, scratch: bool) -> Self { + fn new(name: &str, scratch: bool, bounds: Rc>>) -> Self { Self { name: if scratch { NO_PROJECT } else { name }.to_owned(), scratch, open: false, + bounds, } } } @@ -550,8 +571,15 @@ impl Selectable for ProjectTrigger { impl RenderOnce for ProjectTrigger { fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { let name = self.name.clone(); + let bounds = self.bounds; let trigger = div() .id("project") + .relative() + .child( + canvas(move |rect, _, _| bounds.set(rect), |_, _, _, _| {}) + .absolute() + .inset_0(), + ) .min_w_0() .max_w_full() .truncate() @@ -959,8 +987,14 @@ mod tests { let entries = [entry("alpha", false), none]; assert_eq!(scratch_entry(&entries).map(|e| e.id.as_str()), Some("none")); assert!(scratch_entry(&entries[..1]).is_none()); - assert_eq!(ProjectTrigger::new("alpha", true).name, "No project"); - assert_eq!(ProjectTrigger::new("alpha", false).name, "alpha"); + assert_eq!( + ProjectTrigger::new("alpha", true, Rc::default()).name, + "No project" + ); + assert_eq!( + ProjectTrigger::new("alpha", false, Rc::default()).name, + "alpha" + ); } #[test] From 4ed3a1226b792cd11ea49dbaccf5797aef8b0719 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Fri, 6 Nov 2026 02:46:26 -0500 Subject: [PATCH 14/82] feat(desktop-gpui): sidebar cards, shelves, overlay drawer and composer strip match the web The desktop shell should feel identical to the web app, so the sidebar, composer and context strip follow the web's layout, breakpoints and data. Signed-off-by: Yordis Prieto --- apps/desktop-gpui/README.md | 2 + apps/desktop-gpui/src/assets.rs | 179 +- apps/desktop-gpui/src/connection.rs | 143 +- apps/desktop-gpui/src/history.rs | 65 + apps/desktop-gpui/src/main.rs | 26 +- apps/desktop-gpui/src/projects.rs | 4 + .../src/provider-icons/antigravity.png | Bin 0 -> 12862 bytes .../src/provider-icons/claudeAgent.svg | 1 + .../src/provider-icons/codex-dark.svg | 1 + .../src/provider-icons/codex-light.svg | 1 + .../src/provider-icons/cursor-dark.svg | 1 + .../src/provider-icons/cursor-light.svg | 1 + .../src/provider-icons/fx-dark.svg | 1 + .../src/provider-icons/fx-light.svg | 1 + .../src/provider-icons/grok-dark.svg | 1 + .../src/provider-icons/grok-light.svg | 1 + apps/desktop-gpui/src/provider-icons/kiro.svg | 1 + apps/desktop-gpui/src/provider-icons/muse.svg | 1 + .../src/provider-icons/opencode-dark.svg | 1 + .../src/provider-icons/opencode-light.svg | 1 + .../src/provider-icons/pi-dark.svg | 1 + .../src/provider-icons/pi-light.svg | 1 + apps/desktop-gpui/src/providers.rs | 1251 ++++++++++++ apps/desktop-gpui/src/rpc.rs | 18 + apps/desktop-gpui/src/shell.rs | 117 +- apps/desktop-gpui/src/stage-dev-button.svg | 48 + apps/desktop-gpui/src/stage-dev.svg | 62 + apps/desktop-gpui/src/status.rs | 113 +- apps/desktop-gpui/src/strip.rs | 279 +++ apps/desktop-gpui/src/ui.rs | 1805 +++++++++++++++-- 30 files changed, 3889 insertions(+), 238 deletions(-) create mode 100644 apps/desktop-gpui/src/history.rs create mode 100644 apps/desktop-gpui/src/provider-icons/antigravity.png create mode 100644 apps/desktop-gpui/src/provider-icons/claudeAgent.svg create mode 100644 apps/desktop-gpui/src/provider-icons/codex-dark.svg create mode 100644 apps/desktop-gpui/src/provider-icons/codex-light.svg create mode 100644 apps/desktop-gpui/src/provider-icons/cursor-dark.svg create mode 100644 apps/desktop-gpui/src/provider-icons/cursor-light.svg create mode 100644 apps/desktop-gpui/src/provider-icons/fx-dark.svg create mode 100644 apps/desktop-gpui/src/provider-icons/fx-light.svg create mode 100644 apps/desktop-gpui/src/provider-icons/grok-dark.svg create mode 100644 apps/desktop-gpui/src/provider-icons/grok-light.svg create mode 100644 apps/desktop-gpui/src/provider-icons/kiro.svg create mode 100644 apps/desktop-gpui/src/provider-icons/muse.svg create mode 100644 apps/desktop-gpui/src/provider-icons/opencode-dark.svg create mode 100644 apps/desktop-gpui/src/provider-icons/opencode-light.svg create mode 100644 apps/desktop-gpui/src/provider-icons/pi-dark.svg create mode 100644 apps/desktop-gpui/src/provider-icons/pi-light.svg create mode 100644 apps/desktop-gpui/src/providers.rs create mode 100644 apps/desktop-gpui/src/stage-dev-button.svg create mode 100644 apps/desktop-gpui/src/stage-dev.svg create mode 100644 apps/desktop-gpui/src/strip.rs diff --git a/apps/desktop-gpui/README.md b/apps/desktop-gpui/README.md index 54e901b44ef8..f0da0ef8ad1f 100644 --- a/apps/desktop-gpui/README.md +++ b/apps/desktop-gpui/README.md @@ -70,6 +70,8 @@ Without the variable the same test fails when the committed file is stale. Drift Known gaps: typify cannot flatten the `projectIcon` union (an `anyOf` with a `{kind: string}` catch-all), so `ProjectsEntry.projectIcon` is kept opaque (`serde_json::Value`) through the `OPAQUE` list. Unions without a discriminator generate as `#[serde(untagged)]` enums. +`server.getConfig` is not generated: typify cannot represent its schema. `src/providers.rs` decodes the small subset the composer's model trigger reads (provider snapshots and the provider and default-model settings) straight from the JSON, dropping entries it cannot read. + ## Notes - `gpui` and `gpui_platform` are the crates.io packages `gpui-pre` and `gpui-pre-platform`, pinned to the exact version `gpui-component` requires. Bump them together with `gpui-component` and `gpui-kit-assets`. diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index a931d406b08c..89a784a71f4e 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -5,7 +5,41 @@ use std::borrow::Cow; gpui_kit_assets::icon_assets!( ExtraIcons, [ - Paperclip, Pin, PinOff, Server, Cloud, Terminal, Monitor, Laptop, HardDrive, Computer + Paperclip, + Pin, + PinOff, + Server, + Cloud, + Terminal, + Monitor, + Laptop, + HardDrive, + Computer, + ListFilter, + FolderPlus, + SquarePen, + Folder, + FolderGit, + FolderGit2, + GitBranch, + ShieldOff, + Lock, + PenLine, + Sparkles, + Settings, + GitPullRequest, + ChartNoAxesColumn, + CircleArrowUp, + CircleDashed, + CircleDotDashed, + CircleAlert, + Clock, + Check, + GitPullRequestArrow, + GitPullRequestDraft, + GitPullRequestClosed, + GitMerge, + Layers ] ); @@ -23,6 +57,92 @@ const MESSAGE_SQUARE_DASHED_SVG: &str = concat!( r#""#, ); +/// The web's dev `SidebarStageBackdrop` art, dark palette, faded into the sidebar. +pub const STAGE_DEV: &str = "images/stage-dev.svg"; +/// The same art cropped like the web's `StageBackdropButtonArt`, for the send button. +pub const STAGE_DEV_BUTTON: &str = "images/stage-dev-button.svg"; + +/// The web's provider icons (`ProviderInstanceIcon`), one file per color scheme where the fills differ. +const PROVIDER_ICONS: &[(&str, &[u8])] = &[ + ( + "providers/antigravity.png", + include_bytes!("provider-icons/antigravity.png") as &[u8], + ), + ( + "providers/claudeAgent.svg", + include_bytes!("provider-icons/claudeAgent.svg") as &[u8], + ), + ( + "providers/codex-dark.svg", + include_bytes!("provider-icons/codex-dark.svg") as &[u8], + ), + ( + "providers/codex-light.svg", + include_bytes!("provider-icons/codex-light.svg") as &[u8], + ), + ( + "providers/cursor-dark.svg", + include_bytes!("provider-icons/cursor-dark.svg") as &[u8], + ), + ( + "providers/cursor-light.svg", + include_bytes!("provider-icons/cursor-light.svg") as &[u8], + ), + ( + "providers/fx-dark.svg", + include_bytes!("provider-icons/fx-dark.svg") as &[u8], + ), + ( + "providers/fx-light.svg", + include_bytes!("provider-icons/fx-light.svg") as &[u8], + ), + ( + "providers/grok-dark.svg", + include_bytes!("provider-icons/grok-dark.svg") as &[u8], + ), + ( + "providers/grok-light.svg", + include_bytes!("provider-icons/grok-light.svg") as &[u8], + ), + ( + "providers/kiro.svg", + include_bytes!("provider-icons/kiro.svg") as &[u8], + ), + ( + "providers/muse.svg", + include_bytes!("provider-icons/muse.svg") as &[u8], + ), + ( + "providers/opencode-dark.svg", + include_bytes!("provider-icons/opencode-dark.svg") as &[u8], + ), + ( + "providers/opencode-light.svg", + include_bytes!("provider-icons/opencode-light.svg") as &[u8], + ), + ( + "providers/pi-dark.svg", + include_bytes!("provider-icons/pi-dark.svg") as &[u8], + ), + ( + "providers/pi-light.svg", + include_bytes!("provider-icons/pi-light.svg") as &[u8], + ), +]; + +/// The icon asset for a driver, or `None` for the drivers the web draws as initials. +pub fn provider_icon(driver: &str, dark: bool) -> Option<&'static str> { + let scheme = if dark { "dark" } else { "light" }; + [ + format!("providers/{driver}-{scheme}.svg"), + format!("providers/{driver}.svg"), + format!("providers/{driver}.png"), + ] + .iter() + .find_map(|wanted| PROVIDER_ICONS.iter().find(|(path, _)| path == wanted)) + .map(|(path, _)| *path) +} + pub struct AppAssets; impl AssetSource for AppAssets { @@ -30,6 +150,15 @@ impl AssetSource for AppAssets { if path == MESSAGE_SQUARE_DASHED { return Ok(Some(Cow::Borrowed(MESSAGE_SQUARE_DASHED_SVG.as_bytes()))); } + if path == STAGE_DEV { + return Ok(Some(Cow::Borrowed(include_bytes!("stage-dev.svg")))); + } + if path == STAGE_DEV_BUTTON { + return Ok(Some(Cow::Borrowed(include_bytes!("stage-dev-button.svg")))); + } + if let Some((_, bytes)) = PROVIDER_ICONS.iter().find(|(known, _)| *known == path) { + return Ok(Some(Cow::Borrowed(bytes))); + } match ExtraIcons.load(path)? { Some(bytes) => Ok(Some(bytes)), None => gpui_kit_assets::Assets.load(path), @@ -64,6 +193,30 @@ mod tests { IconName::Monitor, IconName::Laptop, IconName::HardDrive, + IconName::ListFilter, + IconName::FolderPlus, + IconName::SquarePen, + IconName::Folder, + IconName::FolderGit, + IconName::FolderGit2, + IconName::GitBranch, + IconName::ShieldOff, + IconName::Lock, + IconName::PenLine, + IconName::Sparkles, + IconName::GitPullRequest, + IconName::ChartNoAxesColumn, + IconName::CircleArrowUp, + IconName::CircleDashed, + IconName::CircleDotDashed, + IconName::CircleAlert, + IconName::Clock, + IconName::Check, + IconName::GitPullRequestArrow, + IconName::GitPullRequestDraft, + IconName::GitPullRequestClosed, + IconName::GitMerge, + IconName::Layers, IconName::Computer, ] { let loaded = AppAssets.load(&icon.path()).ok().flatten(); @@ -77,4 +230,28 @@ mod tests { .is_some() ); } + + #[test] + fn provider_icons_follow_the_color_scheme() { + assert_eq!( + provider_icon("codex", true), + Some("providers/codex-dark.svg") + ); + assert_eq!( + provider_icon("codex", false), + Some("providers/codex-light.svg") + ); + assert_eq!( + provider_icon("claudeAgent", true), + Some("providers/claudeAgent.svg") + ); + assert_eq!( + provider_icon("antigravity", false), + Some("providers/antigravity.png") + ); + assert_eq!(provider_icon("acpRegistry", false), None); + for (path, _) in PROVIDER_ICONS { + assert!(AppAssets.load(path).ok().flatten().is_some(), "{path}"); + } + } } diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 39ca9e93cb2d..758939d0bbde 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -3,9 +3,13 @@ use crate::auth::{self, AuthError, Credential}; use crate::electron_dpop; use crate::projects::MachineKind; -use crate::rpc::{Event, PING_FRAME, ProjectsMutate, Session, SubscribeShell}; +use crate::providers::Catalog; +use crate::rpc::{ + Event, PING_FRAME, ProjectsMutate, ServerGetConfig, Session, SubscribeShell, VcsListRefs, +}; use crate::shell::ShellState; use crate::status::{Status, StatusSender, publish}; +use crate::strip::{self, Branch}; use crate::wire::{ProjectMutation, ProjectMutationType, ShellItem, SubscribeShellPayload}; use futures_util::{SinkExt, StreamExt}; use std::collections::HashMap; @@ -32,7 +36,14 @@ pub struct Environment { /// A request from the window that the connection sends as an RPC. #[derive(Clone, Debug, PartialEq, Eq)] pub enum Action { - SetProjectPinned { project_id: String, pinned: bool }, + SetProjectPinned { + project_id: String, + pinned: bool, + }, + /// The branch checked out in a project's workspace root, for the strip under the composer. + LoadBranch { + root: String, + }, } pub type ActionSender = async_channel::Sender; @@ -43,30 +54,33 @@ impl Action { match self { Self::SetProjectPinned { pinned: true, .. } => "pin project", Self::SetProjectPinned { pinned: false, .. } => "unpin project", + Self::LoadBranch { .. } => "load the branch", } } +} - fn payload(&self, command_id: String) -> ProjectMutation { - match self { - Self::SetProjectPinned { project_id, pinned } => ProjectMutation::Variant1 { - archived: None, - auto_pull: None, - command_id, - default_model_selection: None, - default_thread_env_mode: None, - favicon_path: None, - pinned: Some(*pinned), - project_icon: None, - project_id: project_id.clone(), - scripts: None, - title: None, - type_: ProjectMutationType::ProjectUpdate, - workspace_root: None, - }, - } +fn pin_mutation(project_id: &str, pinned: bool, command_id: String) -> ProjectMutation { + ProjectMutation::Variant1 { + archived: None, + auto_pull: None, + command_id, + default_model_selection: None, + default_thread_env_mode: None, + favicon_path: None, + pinned: Some(pinned), + project_icon: None, + project_id: project_id.to_owned(), + scripts: None, + title: None, + type_: ProjectMutationType::ProjectUpdate, + workspace_root: None, } } +fn refs_query(root: &str) -> serde_json::Value { + serde_json::json!({"cwd": root, "refKind": "local", "limit": strip::REFS_LIMIT}) +} + /// Doubling delay between attempts, capped (Electron restarts the backend the same way). pub struct Backoff { next: Duration, @@ -154,6 +168,7 @@ pub async fn run( status, Status::Connected { label: environment.label.clone(), + machine: environment.machine, can_pin: environment.project_organization, shell: None, }, @@ -233,9 +248,21 @@ async fn serve( if let Err(error) = sink.send(Message::text(request)).await { return error.to_string(); } + let mut config_request = match session.request::(&serde_json::json!({})) { + Ok((id, frame)) => match sink.send(Message::text(frame)).await { + Ok(()) => Some(id), + Err(error) => return error.to_string(), + }, + Err(error) => { + eprintln!("[rpc] could not request the provider config: {error}"); + None + } + }; // Whatever was clicked while disconnected is stale by now. while actions.try_recv().is_ok() {} let mut pending: HashMap = HashMap::new(); + // Branch lookups by request id, with the workspace root each was for. + let mut branches: HashMap = HashMap::new(); let mut ping = interval(PING_INTERVAL); ping.set_missed_tick_behavior(MissedTickBehavior::Delay); let mut deadline = Instant::now() + PING_TIMEOUT; @@ -250,9 +277,21 @@ async fn serve( let Ok(action) = action else { return "window closed".to_owned(); }; - match session.request::(&action.payload(command_id())) { - Ok((id, frame)) => { - pending.insert(id, action.describe()); + let request = match &action { + Action::SetProjectPinned { project_id, pinned } => session + .request::(&pin_mutation(project_id, *pinned, command_id())) + .map(|(id, frame)| (id, frame, None)), + Action::LoadBranch { root } => session + .request::(&refs_query(root)) + .map(|(id, frame)| (id, frame, Some(root.clone()))), + }; + match request { + Ok((id, frame, root)) => { + if let Some(root) = root { + branches.insert(id, root); + } else { + pending.insert(id, action.describe()); + } if let Err(error) = sink.send(Message::text(frame)).await { return error.to_string(); } @@ -281,6 +320,15 @@ async fn serve( } } for event in reaction.events { + if config_request.as_ref().is_some_and(|id| owns(&event, id)) { + config_request = None; + publish_catalog(event, status); + continue; + } + if let Some(root) = request_id(&event).and_then(|id| branches.remove(id)) { + publish_branch(root, event, status); + continue; + } if settle(&mut pending, &event) { continue; } @@ -302,6 +350,42 @@ fn command_id() -> String { format!("gpui-{}", hex::encode(bytes)) } +fn request_id(event: &Event) -> Option<&str> { + match event { + Event::Done { request_id, .. } | Event::Failed { request_id, .. } => Some(request_id), + _ => None, + } +} + +fn owns(event: &Event, id: &str) -> bool { + request_id(event) == Some(id) +} + +/// Publishes the branch of a workspace root; a failure leaves the strip without git controls. +fn publish_branch(root: String, event: Event, status: &StatusSender) { + let branch = match event { + Event::Done { value, .. } => Branch::decode(&value), + Event::Failed { message, .. } => { + eprintln!("[rpc] could not load the branch of {root}: {message}"); + None + } + _ => return, + }; + publish(status, Status::Branch { root, branch }); +} + +/// Publishes the provider catalog; a failure leaves the composer without a model trigger. +fn publish_catalog(event: Event, status: &StatusSender) { + match event { + Event::Done { value, .. } => match Catalog::decode(value) { + Ok(catalog) => publish(status, Status::Providers(catalog)), + Err(error) => eprintln!("[rpc] unreadable provider config: {error}"), + }, + Event::Failed { message, .. } => eprintln!("[rpc] could not load providers: {message}"), + _ => {} + } +} + /// Consumes the outcome of an action's request, logging a failure; true when `event` was one. fn settle(pending: &mut HashMap, event: &Event) -> bool { match event { @@ -332,6 +416,7 @@ fn handle( status, Status::Connected { label: environment.label.clone(), + machine: environment.machine, can_pin: environment.project_organization, shell: Some(shell.view()), }, @@ -363,11 +448,7 @@ mod tests { #[test] fn pin_action_builds_a_project_update_mutation() { - let pin = Action::SetProjectPinned { - project_id: "p1".to_owned(), - pinned: true, - }; - let value = serde_json::to_value(pin.payload("c1".to_owned())).expect("json"); + let value = serde_json::to_value(pin_mutation("p1", true, "c1".to_owned())).expect("json"); assert_eq!( value, serde_json::json!({ @@ -377,11 +458,7 @@ mod tests { "pinned": true, }) ); - let unpin = Action::SetProjectPinned { - project_id: "p1".to_owned(), - pinned: false, - }; - let value = serde_json::to_value(unpin.payload("c2".to_owned())).expect("json"); + let value = serde_json::to_value(pin_mutation("p1", false, "c2".to_owned())).expect("json"); assert_eq!(value["pinned"], false); } diff --git a/apps/desktop-gpui/src/history.rs b/apps/desktop-gpui/src/history.rs new file mode 100644 index 000000000000..a80e44a65816 --- /dev/null +++ b/apps/desktop-gpui/src/history.rs @@ -0,0 +1,65 @@ +/// Back/forward over visited projects, the GPUI stand-in for the web's route history. +#[derive(Default)] +pub struct History { + visited: Vec, + index: usize, +} + +impl History { + /// Like a route push: drops anything ahead and ignores revisiting the current entry. + pub fn visit(&mut self, id: String) { + if self.current() == Some(id.as_str()) { + return; + } + if !self.visited.is_empty() { + self.visited.truncate(self.index + 1); + self.index += 1; + } + self.visited.push(id); + } + + pub fn can_back(&self) -> bool { + self.index > 0 + } + + pub fn can_forward(&self) -> bool { + self.index + 1 < self.visited.len() + } + + pub fn back(&mut self) -> Option<&str> { + self.can_back().then(|| self.index -= 1)?; + self.current() + } + + pub fn forward(&mut self) -> Option<&str> { + self.can_forward().then(|| self.index += 1)?; + self.current() + } + + fn current(&self) -> Option<&str> { + self.visited.get(self.index).map(String::as_str) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn walks_back_and_forward_and_a_new_visit_drops_the_forward_entries() { + let mut history = History::default(); + assert!(!history.can_back() && !history.can_forward()); + history.visit("a".into()); + history.visit("a".into()); + history.visit("b".into()); + history.visit("c".into()); + assert_eq!(history.back(), Some("b")); + assert_eq!(history.back(), Some("a")); + assert_eq!(history.back(), None); + assert_eq!(history.forward(), Some("b")); + history.visit("d".into()); + assert!(!history.can_forward()); + assert_eq!(history.back(), Some("b")); + assert_eq!(history.back(), Some("a")); + } +} diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index b2b06324884f..829e95bc83e6 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -5,17 +5,23 @@ mod config; mod connection; mod electron_dpop; mod environments; +mod history; mod projects; +mod providers; mod rpc; mod server; mod shell; mod status; +mod strip; mod ui; mod wire; use assets::AppAssets; use config::{Attach, Config}; -use gpui::{App, AppContext as _, Bounds, WindowBounds, WindowOptions, px, size}; +use gpui::{ + App, AppContext as _, Bounds, KeyBinding, TitlebarOptions, WindowBounds, WindowOptions, point, + px, size, +}; use gpui_component::{Root, Theme, ThemeMode, TitleBar}; use gpui_platform::application; use server::Supervisor; @@ -82,6 +88,14 @@ fn main() { .run(move |cx: &mut App| { gpui_component::init(cx); Theme::change(ThemeMode::Dark, None, cx); + // The web's default `sidebar.toggle` and `navigation.*` bindings. + cx.bind_keys([ + KeyBinding::new("cmd-b", ui::ToggleSidebar, None), + KeyBinding::new("escape", ui::DismissSidebar, Some(ui::OVERLAY_CONTEXT)), + KeyBinding::new("shift-cmd-a", ui::OpenPermissions, None), + KeyBinding::new("cmd-[", ui::GoBack, None), + KeyBinding::new("cmd-]", ui::GoForward, None), + ]); if let Some(supervisor) = supervisor { // The grace period outlasts gpui's quit timeout, so block here instead of awaiting. cx.on_app_quit(move |_| { @@ -90,10 +104,18 @@ fn main() { }) .detach(); } - let bounds = Bounds::centered(None, size(px(960.), px(640.)), cx); + // Electron's `DEFAULT_MAIN_WINDOW_SIZE`; the minimum width is the shipped app's, below + // the sidebar's overlay breakpoint. + let bounds = Bounds::centered(None, size(px(1100.), px(780.)), cx); cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(size(px(583.), px(620.))), + // Electron's `trafficLightPosition`: centered in the 52px workspace top bar. + titlebar: Some(TitlebarOptions { + traffic_light_position: Some(point(px(16.), px(19.))), + ..TitleBar::title_bar_options() + }), ..TitleBar::window_options() }, |window, cx| { diff --git a/apps/desktop-gpui/src/projects.rs b/apps/desktop-gpui/src/projects.rs index 4d502fd40305..92489def72a8 100644 --- a/apps/desktop-gpui/src/projects.rs +++ b/apps/desktop-gpui/src/projects.rs @@ -93,6 +93,8 @@ pub struct ProjectEntry { /// Any member is pinned. pub pinned: bool, pub scratch: bool, + /// The representative's workspace root, where the composer strip reads the git branch. + pub root: String, /// The repository key shared across environments, when the group has a repository. pub logical_key: Option, /// Other environments that have the same logical project. @@ -406,6 +408,7 @@ pub fn build_entries<'a>( color, pinned: group.winners.iter().any(|p| p.pinned_at.is_some()), scratch: is_scratch(representative), + root: representative.workspace_root.clone(), remote: Vec::new(), }; (entry, activity) @@ -534,6 +537,7 @@ mod tests { color: 0, pinned, scratch: false, + root: String::new(), logical_key: None, remote: Vec::new(), }; diff --git a/apps/desktop-gpui/src/provider-icons/antigravity.png b/apps/desktop-gpui/src/provider-icons/antigravity.png new file mode 100644 index 0000000000000000000000000000000000000000..ecf863511c661ff7edaeefba93d8a88665793231 GIT binary patch literal 12862 zcmV-EGQrJ>P)>?8oT|%v0%}meIj9Xt5T!dtnZ!s0LE!@oL@^pdj6<5(+6wRS@Y82&7-W(splH4JyCd*9u9sp-_I zdbQSG!@vHuPLj2?Rj(v#YpY&s3b2*0H3itp*O~%s<*SvgXx(wa)Awk~@PIUyr%5L} zXtuvuospt<$T;00rQX(LxoX{cH-BEXlBKfB*Xf?MHh{@mxAA{Wo***|^c2X6hLtkShA72w+!J#k&i+n$`VoTw)Blp#>5 ziNeL7RtP&~yB&*~GE~hP&kL0xnWEIrUc>W6FE$0*wC=;#d|g)AT6GHWt&4Ymg_d-t zjLYL2?CfIy#E}UU_jC2zu^nhip`&8LO%>L_o~x1@(Om3!d(Cc=y+#EoR3I@|ap668 z58m(&8xtmO)vPw}7bruiO)04< zI3}A!ahxeb!LcS`3Lg6$0(db$e!jmB^Gwf9m@D&ITLahk;Eb14gdr*p|i-6D~`!p+v z1U&jT0k;SaM;px{d{-|tQ1Tn@rM zhwZf;Y5PzR*h^-p&QK1{p7Lp6v60p(13Z8_wY4k(Mx#;4)!t zoayHg`QZU00z`loE0~(tN7xs@DH1+bWrrk#_uG+&%L-a6$_0Gp^8J3HDa}0gj}E?jeuI%`wJC^YG8_Y@^+uR>FH;KMxeq zwtWL-g3z)3bMXKWu>$tKJ>S4gPziLDn)J-||LbqQBP(F77#FbaiU0o%qmuT7;4L4Q zG(rl8)tXR=*jgfJLPlt&?wk|dL841QumkgrR1oRBxdv#5xdEh#h8CC{%}v;2D*-4i zzC{t(-U1X5zD81lC4p10_`yz^>^10LEV>f=4#S-5dG`1iSpjQBSb$GlbZH#bYp z+i{;W15QRdmWL)jf{P)NV@yJp*z`B5M@px=e`pB%qNC#z9JVO{EQ)ukD zI;c2T0eHlyzd_bO6tN~d20@z2{v`!78nYeEtf?BIoo;uWNNaKjBD+-!mcWaH%sFkjND!e1YhryYKJv zt<%2z^_ygh*VJpM8R>WDJTS}Lnl-jvS!~CqK^fQ96rmOD>T&ViI-3X8cDr}Ia{Xk=MLTnd74rhnL_5JE9OV8S7@ z2aZ=Dx8k6%`FyYIn@uohfN;<@Jc2*?dW4X$3wFOn7D1rj!Ty4iqpD~J!k8x@J=yb4 ziW<>i;bS5KGzisWPoXU-G$`h;p75mm9{+NYTmM+5bWODajN9f72R#_NE1h4mm*MXZO7*fm?)tvWLW^fAd^&Xh02tmMD}Aq+8Oy{OHF7Q z6WFc!9H7AsPg=~jK-?gim_pclJKOH~8ZE&g^(;deR0oEdtSk~C zrE3tiTSm}A#VqnqqoMEP`z`OH+-38vNpjMB_+*pIxv&~V%D$=&V2mpNOedko6BGy<>6U5;Jd)u`@4 z4^ZSmTnIrkTuWv+kO!nnFAig13NbKow#e}caen|nq=Vp75ECX7IB^=0}8@{n8|Ek-!v&Z7?6Vf%~!>JFK*HI)i*!PPH6pzHK$$hDOfIe{Y7 z;y)+ZJWRO=*+ChU`_AG)+l_pUYS7UF;66YhWp<9BuP_M|l<-(QGF<>nY`fdD;(}Un z2fqSe-&sc3&;%7T18tg+#_&2~|LUJ!8B0*^Py)CY7^=)5rXAOHziy{~uT0sRN(Hds z?6j!#4yt^`#7|i zYk=P`sPdU+nEb5L#PC8vBt_cSB4a>NnNQFcAl7FE5=u3;4{lL-fDSZ;mH-I~K0GC0 z4;lLLTA4xxn7SbFyi1RNR2kOqao1;cP$%QJY#96F7S^D4WPdWuW>FY%0V0E**=w&+ zB~a-@i5JIoh~P=-sErkQ2E_F2{+L0WZBr9|A*3U%DMgFpP5n%!YFilD08|f_gYYk3 zM~qOMV%k;~i5L(%6A|Z~3l!~(z2xy1Uvjxj(V997P-fCAyIi?T!Q?K5f+oBw!cC?0 zwIFE0E1L_zhJ=$YPzpxg>Iu2$2do7UuLnw>oc_h)oxQ)!?nEF&Hyo7#CoPQSd3*z` zMWS}eAQuK43FX8f8z77v5M(gi0J&&*gqCmwk^@)>WRwYRV5$^g>X1M))hA@Kc@T09 z%@rF0$Y>jQe{12^K#-h`yMcB@0~;~K&;Jtlt3~OC@Y!)A8Cw3HtRB?LtT}?Q#qP6p zXjR`C3m@z?;h&+4h7;IxeBGWOH3NI!pyJPJ2^G&7I%rv?eXjkDELO<&ydjc?I1do> z17)=5TA+xm6BNAPz7uK+)lo<&%2Br+e)xKsqBT_(;Ec;ocvfd6-%4l~z7s_~%dCOd zb8jgFF3@)o)&;BAw=??r2+Jyy^u$a7#a@<%nIoc(&@zNWPG7M95oUP$)?vpMz_|is zhaM#-I3QDt>Yz>snn9K!`9Sa~C?sp(QqB@d>NKPlz!PfD871e$=k1HnlwmsBKJtIa zl&q;z0NXLILf)UfcmrWF*{U@CW)UoJE1n86T>}$0B7ndG zw2a`(8QWRbFfynKRB)Jg2)OubNFv(m3=f1JXd?lIY{24QHcOKg`Tu}@1(|t2R%ArxsM zZb3*0!6TV01|>3cfGI#+V7s2_Vs>%@PeMllH7xsm?TA1>0++ZC7IU$km&u3ph=b7$TJcpbqf_+qDoc zAX}CZ3{*(f!!$EDCd&pLBlbD8J0|GDZwGLV}2Y=N2^b;owL+>HA~~*7R5aO~-WR&za_l5bcA1XQ`D+fMz{l0!Z#4%)Val zmZ6H*^I0J32*24TC5UTsXs9P(@sR69d_kgMgQ53qLgG8L@!r zLJMqJ2tR6=cqFcX=_n{by-;z6%ilM&yLG;c9ZGp%lB(v>ok+|(w5;-jfunn6mSs=E6 zn89#91yIy7O!P3qb4q|go%2L+KlMN`fCMNe(#Fi#0fmBM!XFR{W=Rn}2fzi$CA9dp zj!_gc;g69cXnO7~#~i=&o*(DHZ-(Euo56nHl`mUop2$PSn zpHK{Usb>K8qXTn=f}jqE%?V2YxCb;(gXW+u)zUq1`sf_kDGIbsxtn1aSk}#?de#hK znxHMn6OS#k`VZxtabbqgpd45W>(v?bQr3r2JFxEqcK}>~At1n`4Bf;kvUc2nU=18MX^+{27ST?18@jXG-{PQUxz3l znX(RO5mE@;)58|lfIVxbfEnQ}2Mj2|?UD|YH3jfxK+n;FK5KD&000mGNkl7J*6Bv&H(Cr*zPu;nd_0w8&i2#&amaRK{ykSZeS>k3mt+x?9?Fqe2H(8^$y@y^7$ z(a^(pGc0+O69YPsz8WA6MGO(PN1&o+bv`hne*ioXa^|FwmTf|v*%W zH24VTv>em|MiF)@!=mH^zJ1J_*FEWD=iMjExRzZ3PQ3J#-Oc1b#qf|3V<$^ww>G7I1Ab&@#Y)N`2s;-oFIhFBG1(vC(E3lRp$ zKF2`|L*!crg&$6m@{ zUBDG6GX=7Rc=DE{Ubc$R3% z6@vNB5&&P2mB5C~4lnVUoq)*M`oz~C+(mv~&= z&x`LxmWDaTOT111um=PY>{+IRd<{^aDMe_15_JJn5=#YVShzJKjnCpS_BrOgQ_7YLblBbC0GK$(btvOz zpvdC!T1^8BE5I#i`QEYeb>VXjUYn85ZjtT%@IE`Ho!S3W zuYKRLaRipl0=)XuXYFPF{&5WQeCJoBPxP|10?7FjAy@!Gh5Q=q;DHOga0(cUMr7t? z=LjU9?&Sre>`mb$YZXrWuuaQA&~O>BHsL5577+mkpzk~e#zkc@ibS|U;s76!D%B44 z#ROb{KFQgjEpZD>olu8}Nn`g(699;uRGOK=w6rLQ2C_Mc@e5^{vVmn&fN^OKE5Y<3 zz{6di{1z=;ECg9R&@t~Px`&-#IGCvk>UDC;I9MEP6h(26bpiL)!VnKZpL#QABJm6D z-l*sSn9F1fBMU%|&+;9-wW%8dPvq_>7@+ga%D^VH%!*MapkS}30rQCL{C_iAgHG-J z*?Y_&?CO9KaR6$XijOJfe4Cg%mzFIZTs8$TQ~zRNxp%;#jr;pZ`$E{P0u}~_LJ~KJ zJ+_2i!S(@vTcUJ~T%WDnpa`Q1$iiO3X^&!=1Alm;MokTEV9f`_5$6C8oHbx^L(Z&3 z*(W8E1V}5Y5>q0DrRRqEy^ksYKOG814Q#|GPx9o+6_MYKgNlG#V5*5%9dXN!Q>r=Oi4 z>;g~{{ENEV)-o4*dGFw@4C=No&sQY!ns{QOt{ohX%`c8A zaL6A^1LPmJ-ggY>hc1D22+RkHIg!%a0ThZMWE6rLx+DckIJEm{V>eJikx}^Q2yZq8 z0OZ97^Z#vju#vV$X)>&AfH+|5PypM#2ZM3%xITo|@-}?8gE|ffai4k21=H!No>R5$ z*UZ5IsGd?4^1d3*@dKojz!J(X?~>RJ;KV#)ejXP@7f&MK>ri1uGe_5XS;ZOq5xji1>00$2Y%fOX|%FuW}4&#J@#QS2BbL%BpT8ai>Qew@o zgCB0y1(-9kVdZOMOYu61)RL(n z0V&`MWDwx>G3e;YQpOfk^cjr1VVY^FVo=S^fwU+-&Fp`{@BYP%Eb&@$3vl?yKe)R^ z-cO+4X2jovJ?a8w7Y2Uh#@oQqhwB}|Q~-7Vt2&$uk z8s?w)J=kN30U<0$lWMpb0{HafLJFx*lN{K6l$c?+AiRbrD14Jo8%I#m5^}=l@T(Al zQd%DbEsXj>M=}K-vf$nM#0yxfCeIjhv9Eo(OJxbyk}H7a{x6C^j;2)t=_AiS%E2|< z0?8aXcD&QV_~SwNj`*p&hCUNWZFCT~H2nz#^kwVt04}!=X8D70X72X5WClSy5GBCxqu_-;qRc*q${^7+2X%qB%Mz|7O#=`8iwkDDJ9qqn{pd6O zj`zTiyS)TB&WeZUhKnJqkT9Y800PDpDD+4W&e=IE&QeE%Yv=vwh$2W?_6Py~h6>Gy#)?X1 zTUderDND4LQ~@krI+)h7xGxv__h9!B+GVO2QGMk6Wagwf25=uYU|$l)3A9g~_)+Sd z=_gm9`sBVKh`>$rdG9Jn;uB@)<(W=uPZ|wg4=jPQ@}IqSNH(A_9w2R$kP&;%iT9CJ zKny_$8?g5fp(HAk-fTj~FlJi-q7r~a96^_PF^Z;XR<8nB`gaK1Z(#DP#%}HzJdKxe zo!{@~90JN-|My^#7NGhN*c5CVV)~?W*M3rv@~Tk&fWVIrbYuut{hwTXz?ej+$EiOW zeS{hl=Skp>LzG-aMtTkqH)M`6pyR|b%n)E#GFZhZ^V;jU^q4CrCs?&qhq&N?1mD=w zKs2EwnkFQgda(Eu`zlMYmMjK1;KP6RxT?di`9w_zeJI2}3n2X7Ru9jc*uAmiSv7N} zP_4Zy2zNlzU-I89yYStjr)UwAFyHvK!`k;58Z-b`{~);NRzukv;4wi8C16+y-;XU$ zkRTDEKm{;Vm-&e^6SA`oSWwdH*C|8BPt_%yocg%I%UdjNCx?=0EiC0c=+w>6T9V7s zEtv%{famc3(heHw+(dxf?Htt5#zG9KzI&fn6Yah^aHUd~FnTQlHB8 z0ipCTZ!i2B-N0n|aQs*D>%B;!ufS zktJG7;sW-#=%V5F_iq0K^O8fFehL*;zog!073O^65*Ql<{hJ(mcLU<7T+qAvSpdQP zWA}yI`07C)H+x*&!-u@~QG$C|XL0Mq<2q+JpnQKAR>VI^`xaF7T#}FD`UKvXMl8`& zL!P=s2wO=&LomUr2MgdeE@kNrC~e$&heMHdS)a0b^S`-M7I`g+1=w!(kq4S^hV=6Q zdb+^x)gfHs`njFM_2C(dmw$WVi_Qg#y{q6VJ}=hGy}e>kgnpGELb84=!CO!L5WU}N z`uBGx@oViFG--OZxVIh=I)rzK_kmEznFM{&$tTqM#0sBgX81z3zcH;vBYyp9O=H$6 z+!~Cb4zLss3)7->q>gb|HR=v}ZzBiG609YO0j%VGhzEDA3zVf=j|FP(Q@GRzdfW?r z>gI{N_G8{%5qhdu@c%tnjIAA0LrLDK-v4cnuQQo`-k!SLe)_Ks1~cEf@;~bRTC$$= z?)oG<)e{Y``z3qtf#&1)x8!1bhY@RBBuaup0_k(^2Zz@H(QDMxz7&{5_B@t>L=!zn z#hrzB9Wy9NVIxWpE7Q6d&RIeNP`f z$3F8!6XnY_qDIdVys}I}7|O#)D4M7zh=&zyI*Q4o$Wq4`A17>DWGI|{{I!xVQ2NW2 zWuI+OWl2~2a}^000mGNklSyn^q2vU$sf;klwS3l5@O zzDfVD-TeCVP8(m?H0dKXmz|Rr2-rl<4kV{4Gg=5Q#VZvyk#V9CkyXm%iJ*8LQ#&@Z zJGTX!gy|vAoy>L0qAI|m2Z81{597s~y>2hgw^V2Kp!lQ1G68#vWA@GlN)GWBhgkf& z0c|jAIsGGQ2~dRQYxbiaxMjmO=k(WkT~rok0j?s|4=_0u%$C1Otoh#tq3unRt) zDrtXBsf<1abp>ZfQc96CB$SImGQp5jMZY5nCEC7N7I`hI3)u6&UA2>iNni0D-10g+ zsZIhtVC>bg0inSC+MI3d?mz z4wwMSo->F&J(QrP1Z73~c?3E~XrCa0P>;Ykc~Rn~gkwycYqIC>Z`k2GvPf%DEr9*w z4<|^9hK~}Sv3kjt?bk&oHG!TFFjw%I%ke9HyF>seQAJ}g_E&eUn>lhhgpc)wQwG0h zu-#jIA`MG`-fKu8$x}y8G@+>dVxtn5o@JEC-Uqh;5gw901k2FTdnTDxUDQj_E~)}p zq<>U|NY1n&S~g@`eGSMYkUp8DfpO(6LhaJb1%ZA^9<4okXTnd98XozTH|ne`wq)HXW5tg54=tc8`NSvr8+F-q>_Ht8ROrQ`o=c(BpBDBZ+s+HT)5g1#db8i z+xt)5X;B7CE}8|XIyh3i9ReJG=iXE=<2omRxqhuWX8|U8e@dR>Www9E88s-D`opFf z9yLwE$J+U+?MC~`*VNJzOPvNTenDw>4dS;oCKR%jP1MajdxwYu@&cGnMs-yf&8&Z! zEYeyu1+a21%zq8@?a_RhSMm*tH-#RtKp#!l9v=u;HqgT`{>>||eG37bsahX9R&#q_ zbEJVn->pF_;oLz+hrGh1mS7`uB$4_&VTt~PTpbGol9}9ceYu7%Jkd-X7r-uiw6N%Z9&Xj^e8iHFaRQGoE#tTNv<3v;A5*TJm-Wn?|Hl|!de^)pxu#?x7R8? zsl!b@2Xn=J=kSGZuT78JOJB{4>j6pBbg#eRzHQEwDPLD^)Gyc#MsR4*zYt0cLplD^bYszu5S&OWI^~3wp+wI@-_mfUS=Mn^nRka^0lf(S;qU6D8 zWF{anDJ`c^2wnnlQQW|SE5NQ7-tYu-e81>0Eljv{Yg}&}X`&O$B$T9&6a0C)1w{*+ zKIfQo?|p(S@LF&M(5`)@vv=BBdJMn|>rw3S{#>WcIXo;np>R1ngWb7(W+2yoapQ0* zCTl;Kb;U;gk_qw*=}E~V94;Zm0BGg|GIr#;G(?qY;%byPJP?#0P#qzd>7893U#( z&wWtqLPoh5<7Wyp1#$O~yqc8Ri0bta%PdZC=yc)Mf-1o7JACOyR?T{>3p48a9@erE zwCDKG)$V<%kKB>oO^8MpzslKPJY}1&$O>JbHmssa@*3`Jb?Uf8iWqW?hDG=JejyBw zK~avFPM#rEpHPfAVdtaH=@yhPxUd#L+gEu|htjC?;eEjJ{ODetKNUgWe8Ich*FGTp z#tj>0-YF}5U3Rj5-hPaW90pX5rYED&S?p0WDC$Jmo{;XIftJB*VFXIOqFRoZ1zZcJ z08hK{D?2JipC%vL?88=4q`cnZc@rl zJ*8wazar8br=k<}#1M%ZolY4X5;m8kJO0ok&aXSk0<8s8fOcGeQ`l+i*WB(e-|CH< zG1xIXqUZm>2>3$eUMlnZrX<(gu(ADwtde!*+w>m|eMdBzFM88U()d8fc#o664SYB8A#~@OL=GCmR3C5 z^@|-=_m6}I0tV&;{0JP`pGYpG`USOAc0OW(g24q*0IO{Lj{l3i!pB|S2Yr(`z!g>} zyI&`*U5ptLd;aeorsh;xrR&MNr1#m*_-d~Np6^RfVfSfe5f89Hq)rN{5daomDMO=u zOiq*qSPxqPcKiLW>}O_v?@7llOT0~hKrsVgUf%Cx+%#ILbV8_$sPXJ;Pu69>lGb7^ zR1EEBInm?1*zpv8BtC8lNQo%DLImNNlkSoe$h-+f4t(huqi4y(To2y@bm>i#s-1d6 zM{oAMt9D*RFCyI3bN=U$0Nne|J?q=^WR0_=MJSD#}${<-w;f$vM0_QXWD&+q#fU{2uv|2gF_zxS4D zBY7;=8drIX1a~>;Z`yY)5+vf!<|&|G^2jj)4-DtIic$|bR)2)`Py{ zqt_hmYw9Gcm;Ux&C_&s>81UCOZEQa!Tgkfa6#bg*^52{6aB)-6U$(N8ez1u*kqc*w zX#hhSFnM5R$uNe8u^zGlJpG(+>}}s*-{ezuz2^^$L?kkP-V zh{e%C`cYbe>H(tD%X#5*DSk zkh;Sf+<5(l;RUjlt}CrhU{e1u?)N6WOxg)O2GAZ*t$nf-#Z#Y3#nDo zIU+suX@njj_6;Ys%MXbz_n;MEw{yRFtbLDX&PVv%vW3_2qn|17CPn_n^Co9+U#?_JP~C zGeMsueYuBjyy>+zoY|zackYdWZp_02ZoJ_~?b))nxUN1$|I&8yf1emkbd31r(B?8_ z@!MScVp3oTJkBkGp-gA)b8cNH4`Mwi1xR=O=u}gH?ffE18sibdr<3uJoYRy%CeYgN z_8tcA$E~!#u~G=0OSJ|nZ?^RA+r6}rES125O8>j{SpW}^-aTmcA_9VkooBl8punLA ztN^>8{jEdH)E};K3HP9PiGShV1%yKMwNCX~K>Um1*!rfMEf1Ep^<(Rb4SH8f`YLCS z&*g;uc)Q+u)^MSdW>Ze3b!L%c1oDOh-!neofqw1-Qh;60{`<#hYBoF47qj~C;l(-r zA>2A2+3Wq4_OrKKcRzLGDZ@XKwe=I%RVV8e_Pf2!`vk%v1Yvj+1Garxwq_pjOOz4m3>-rOxPXv3DFjXX~fZ-S>eSQ3LUtTve_-$ER zKXYAkidr;ZKi!Y7t35anMzsd0R--;pLI^Aqr+|v9?$p%$2M^@){#6Bd#(DpEvI%wX zfOmt_uTJaGKL2m}+&(VRsu9Ho<)`R9waLf*q{&=s>;CJWSv}5vxo`HDE2)7Ifi)sM z4D3+A#I?Ew=q&+fCm(viS@})>+GqVt1$f5Uw;gGpaxBt3_xS0@%{+sWW^$^2@Ooqb z>f|uHP5Rp_sj*|Z*R7lNZY%a3Z9m_AeMrcg6b-aGmm2!zt6d&BI+3z#1X}MmKL7Ob z&zv{-Qx)JBF8Kbl45vNC=hk^}=d<|Yk}V#A@Yu;v=00@kxlHc&-+ukZ_S3Sq9@M(} zB)!Rg##dHn)AG5>>E%utkFrWUFJWyZ(n6LA6mtqoYA!tBw7SPn`<$Ps0DGPFy&db= zkfrL^*ih*s{=SFbnfr66=m8-gsw*-X(f%hE^gSS7w6^ZQuHC3sA=NiX2-roe^EPA9 zgJgmj$F&KAg6*@9*W06yz2MyX^q=$@_fvqUU+@nz<5bSFoxi;gcSw_fF9r02JfCCH zCg$G1>>E5|&fEMnPa^Us%}cJod;Q?m zYhcg9tgAQbN%pJ$`MfGx6pbcStO-W|=0EqT0v=+Il2}?Flk?2S_B-R=C(l3kk14?J z=YH?CcAgjX{rYny-0<^#dy4IR4oP%WB2qq|h-KgGmPYS7c57kxEY&JMXYoL-mrNja z4MMSm|L^?+uXXEFYMgHvWf->*k1e{*AHU$d`l$ZA69st2dEb3Ws`c2E-G3_^y5@mC zwFhknb&@Lgx|4a%X^K}RpL*FI-f zQpqUMJ=e^zOC;W*BkTEA9WYW%V{xcqVQ;r^Uvn;H=Rytf;3uUU%v#EqMVtFD`8 zn%BQOz5XYYF@)$-@r4(!h`7&(@>I{ua`mfT`Xx)*ZUD%jPy`u@0005cNklJKi~34{}Dy?On5ywaY-d{>l|UwYFiGar?;HI?hSQ}veXHt7pZ z;;%Q)eH(MS;`3R01kpQ={n|#V(|!8uR|}WGq>L>$D6dDZTZ2I?5U?3u)jT;j?DyIZMofhuGv6qF}I$1dVO?F zqn%65_uRU1{kLRot@@?1wzlf^@Y}=J){0(h3b2*0H3itp*FOUQ0RR8b*Joh>000I_ cL_t&o0E1-Tg`Fb5H~;_u07*qoM6N<$f|an!jQ{`u literal 0 HcmV?d00001 diff --git a/apps/desktop-gpui/src/provider-icons/claudeAgent.svg b/apps/desktop-gpui/src/provider-icons/claudeAgent.svg new file mode 100644 index 000000000000..9f5d73fe692d --- /dev/null +++ b/apps/desktop-gpui/src/provider-icons/claudeAgent.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/provider-icons/codex-dark.svg b/apps/desktop-gpui/src/provider-icons/codex-dark.svg new file mode 100644 index 000000000000..15af54d3d91f --- /dev/null +++ b/apps/desktop-gpui/src/provider-icons/codex-dark.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/provider-icons/codex-light.svg b/apps/desktop-gpui/src/provider-icons/codex-light.svg new file mode 100644 index 000000000000..55367e40c87e --- /dev/null +++ b/apps/desktop-gpui/src/provider-icons/codex-light.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/provider-icons/cursor-dark.svg b/apps/desktop-gpui/src/provider-icons/cursor-dark.svg new file mode 100644 index 000000000000..642d079966d4 --- /dev/null +++ b/apps/desktop-gpui/src/provider-icons/cursor-dark.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/provider-icons/cursor-light.svg b/apps/desktop-gpui/src/provider-icons/cursor-light.svg new file mode 100644 index 000000000000..e6d2301337fa --- /dev/null +++ b/apps/desktop-gpui/src/provider-icons/cursor-light.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/provider-icons/fx-dark.svg b/apps/desktop-gpui/src/provider-icons/fx-dark.svg new file mode 100644 index 000000000000..55715d23b3f6 --- /dev/null +++ b/apps/desktop-gpui/src/provider-icons/fx-dark.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/provider-icons/fx-light.svg b/apps/desktop-gpui/src/provider-icons/fx-light.svg new file mode 100644 index 000000000000..d30d76b2866e --- /dev/null +++ b/apps/desktop-gpui/src/provider-icons/fx-light.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/provider-icons/grok-dark.svg b/apps/desktop-gpui/src/provider-icons/grok-dark.svg new file mode 100644 index 000000000000..5fa42ea2ba6c --- /dev/null +++ b/apps/desktop-gpui/src/provider-icons/grok-dark.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/provider-icons/grok-light.svg b/apps/desktop-gpui/src/provider-icons/grok-light.svg new file mode 100644 index 000000000000..3dda90aedebe --- /dev/null +++ b/apps/desktop-gpui/src/provider-icons/grok-light.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/provider-icons/kiro.svg b/apps/desktop-gpui/src/provider-icons/kiro.svg new file mode 100644 index 000000000000..227e8bb1df28 --- /dev/null +++ b/apps/desktop-gpui/src/provider-icons/kiro.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/provider-icons/muse.svg b/apps/desktop-gpui/src/provider-icons/muse.svg new file mode 100644 index 000000000000..35b60189260a --- /dev/null +++ b/apps/desktop-gpui/src/provider-icons/muse.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/provider-icons/opencode-dark.svg b/apps/desktop-gpui/src/provider-icons/opencode-dark.svg new file mode 100644 index 000000000000..6eb221fcd7b9 --- /dev/null +++ b/apps/desktop-gpui/src/provider-icons/opencode-dark.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/provider-icons/opencode-light.svg b/apps/desktop-gpui/src/provider-icons/opencode-light.svg new file mode 100644 index 000000000000..7a5be4fb09ec --- /dev/null +++ b/apps/desktop-gpui/src/provider-icons/opencode-light.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/provider-icons/pi-dark.svg b/apps/desktop-gpui/src/provider-icons/pi-dark.svg new file mode 100644 index 000000000000..eaed20e71923 --- /dev/null +++ b/apps/desktop-gpui/src/provider-icons/pi-dark.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/provider-icons/pi-light.svg b/apps/desktop-gpui/src/provider-icons/pi-light.svg new file mode 100644 index 000000000000..2d0c22f72488 --- /dev/null +++ b/apps/desktop-gpui/src/provider-icons/pi-light.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/providers.rs b/apps/desktop-gpui/src/providers.rs new file mode 100644 index 000000000000..ab0c329ae4ed --- /dev/null +++ b/apps/desktop-gpui/src/providers.rs @@ -0,0 +1,1251 @@ +//! What the composer's model picker trigger shows: the provider instances and the default model +//! selection of a fresh draft, ported from the web (`providerInstances.ts`, `ChatView.tsx`, +//! `ChatView.logic.ts`, `ComposerModelEffort.tsx`). Decoded by hand from `server.getConfig`, +//! which the generated wire types do not cover. + +use crate::strip::Defaults; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Deserializer}; +use serde_json::Value; +use std::collections::HashMap; + +const DEFAULT_ENABLED_OFF_DRIVERS: &[&str] = &[ + "cursor", + "grok", + "fx", + "kiro", + "muse", + "pi", + "opencode", + "antigravity", +]; + +const REASONING_IDS: &[&str] = &[ + "reasoningEffort", + "effort", + "reasoning", + "variant", + "reasoning_effort", + "thinking_level", + "thinking", +]; + +/// Variant words that name a tier of a family, not a model (`modelShortName.ts`). +const VARIANT_WORDS: &[&str] = &[ + "pro", + "flash", + "mini", + "max", + "lite", + "nano", + "ultra", + "plus", + "fast", + "turbo", + "preview", + "latest", + "thinking", + "reasoning", + "build", + "code", + "image", + "vision", + "audio", + "live", + "custom", + "tools", + "exp", + "experimental", + "chat", + "tts", + "stt", + "non", + "transcribe", +]; + +const FAMILY_PREFIXES: &[&str] = &["Claude ", "GPT-", "GPT ", "Gemini ", "Muse ", "Grok "]; + +fn lenient<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: DeserializeOwned, +{ + let items = Vec::::deserialize(deserializer)?; + Ok(items + .into_iter() + .filter_map(|item| serde_json::from_value(item).ok()) + .collect()) +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct Choice { + id: String, + label: String, + #[serde(default)] + is_default: bool, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde( + tag = "type", + rename_all = "lowercase", + rename_all_fields = "camelCase" +)] +enum Descriptor { + Select { + id: String, + #[serde(default)] + options: Vec, + #[serde(default)] + current_value: Option, + #[serde(default)] + role: Option, + }, + Boolean { + id: String, + label: String, + #[serde(default)] + current_value: Option, + }, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct Capabilities { + #[serde(default, deserialize_with = "lenient")] + option_descriptors: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct Model { + slug: String, + name: String, + #[serde(default)] + short_name: Option, + #[serde(default)] + sub_provider: Option, + #[serde(default)] + aliases: Vec, + #[serde(default)] + is_custom: bool, + #[serde(default)] + is_default: bool, + #[serde(default)] + capabilities: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct Provider { + instance_id: String, + driver: String, + #[serde(default)] + display_name: Option, + #[serde(default)] + accent_color: Option, + status: String, + #[serde(default)] + availability: Option, + #[serde(default, deserialize_with = "lenient")] + models: Vec, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +struct InstanceConfig { + #[serde(default)] + driver: Option, + #[serde(default)] + enabled: Option, + #[serde(default)] + config: Value, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +struct InstanceOverride { + #[serde(default)] + enabled: Option, +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct ProjectOverride { + #[serde(default)] + provider_instances: HashMap, + #[serde(default)] + default_model_selection: Option, + #[serde(default)] + default_runtime_mode: Option, + #[serde(default)] + default_thread_env_mode: Option, + #[serde(default)] + new_worktrees_start_from_origin: Option, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct Selection { + #[serde(default)] + instance_id: Option, + #[serde(default)] + provider: Option, + model: String, + #[serde(default)] + options: Value, +} + +impl Selection { + fn instance(&self) -> Option<&str> { + self.instance_id.as_deref().or(self.provider.as_deref()) + } + + /// Option values by id, from the array form or the legacy object form. + fn option(&self, id: &str) -> Option { + let text = |value: &Value| match value { + Value::String(text) => Some(text.clone()), + Value::Bool(flag) => Some(flag.to_string()), + _ => None, + }; + match &self.options { + Value::Array(items) => items + .iter() + .find(|item| item["id"] == id) + .and_then(|item| text(&item["value"])), + Value::Object(map) => map.get(id).and_then(text), + _ => None, + } + } +} + +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct Settings { + #[serde(default)] + provider_instances: HashMap, + #[serde(default)] + project_settings_overrides: HashMap, + #[serde(default)] + default_model_selection: Option, + #[serde(default)] + default_runtime_mode: Option, + #[serde(default)] + default_thread_env_mode: Option, + #[serde(default)] + new_worktrees_start_from_origin: Option, +} + +#[derive(Deserialize)] +struct Config { + #[serde(default, deserialize_with = "lenient")] + providers: Vec, + #[serde(default)] + settings: Settings, +} + +/// The provider snapshots and settings of one environment. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Catalog { + providers: Vec, + settings: Settings, +} + +/// The two-letter instance chip at the icon's bottom-right. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Badge { + pub initials: String, + /// Background and foreground as `0xRRGGBB`; `None` uses the card style. + pub accent: Option<(u32, u32)>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Trigger { + pub driver: String, + pub display_name: String, + pub badge: Option, + pub model: String, + pub effort: Option, + /// The model's full name, for the tooltip. + pub title: String, +} + +struct Entry<'a> { + provider: &'a Provider, + enabled: bool, +} + +impl Entry<'_> { + fn available(&self) -> bool { + self.provider.availability.as_deref() != Some("unavailable") + } +} + +impl Catalog { + pub fn decode(value: Value) -> Result { + let Config { + providers, + settings, + } = serde_json::from_value(value).map_err(|error| error.to_string())?; + Ok(Self { + providers, + settings, + }) + } + + /// The workspace and permission defaults of a fresh draft in `project`: its override, then the server settings. + pub fn defaults(&self, project: Option<&str>) -> Defaults { + let overrides = project.and_then(|id| self.settings.project_settings_overrides.get(id)); + let settings = &self.settings; + Defaults::resolve( + overrides + .and_then(|o| o.default_thread_env_mode.as_deref()) + .or(settings.default_thread_env_mode.as_deref()), + overrides + .and_then(|o| o.default_runtime_mode.as_deref()) + .or(settings.default_runtime_mode.as_deref()), + overrides + .and_then(|o| o.new_worktrees_start_from_origin) + .or(settings.new_worktrees_start_from_origin), + ) + } + + /// The driver and display name of a provider instance, for the sidebar's provider icon. + pub fn instance(&self, instance_id: &str) -> Option<(&str, String)> { + let provider = self + .providers + .iter() + .find(|provider| provider.instance_id == instance_id)?; + Some((&provider.driver, display_name(provider))) + } + + /// The trigger a fresh draft in `project` shows; `None` when no provider is selectable. + pub fn trigger(&self, project: Option<&str>) -> Option { + let entries = self.entries(project); + let (entry, selection) = self.default_entry(&entries, project)?; + let provider = entry.provider; + let requested = selection + .filter(|selection| selection.instance() == Some(provider.instance_id.as_str())); + let slug = match requested { + Some(selection) => resolve_model(&provider.models, &selection.model) + .map(|model| model.slug.clone()) + .unwrap_or_else(|| default_model(provider)), + None => default_model(provider), + }; + let shown = provider + .models + .iter() + .find(|model| model.slug == slug) + .or_else(|| match provider.driver.as_str() { + "opencode" | "antigravity" => None, + _ => provider.models.first(), + }); + let display_name = display_name(provider); + let (model, title, effort) = match shown { + Some(model) => ( + trigger_model_name(model, &provider.driver), + display_model_name(&model.name, model.sub_provider.as_deref()), + effort_word(model, &provider.driver, requested), + ), + None => { + let label = if slug.is_empty() { + "Choose model".to_owned() + } else { + slug + }; + (label.clone(), label, None) + } + }; + Some(Trigger { + driver: provider.driver.clone(), + badge: show_badge(&entries, entry).then(|| badge(provider, &display_name)), + display_name, + model, + effort, + title, + }) + } + + /// Provider instances in the order the web lists them: grouped by driver in first-seen + /// order, each group's default instance first. + fn entries(&self, project: Option<&str>) -> Vec> { + let mut drivers: Vec<&str> = Vec::new(); + for provider in &self.providers { + if !drivers.contains(&provider.driver.as_str()) { + drivers.push(&provider.driver); + } + } + drivers + .into_iter() + .flat_map(|driver| { + let group = || self.providers.iter().filter(move |p| p.driver == driver); + group() + .filter(move |p| p.instance_id == driver) + .chain(group().filter(move |p| p.instance_id != driver)) + }) + .map(|provider| Entry { + provider, + enabled: self.instance_enabled(project, provider), + }) + .collect() + } + + fn instance_enabled(&self, project: Option<&str>, provider: &Provider) -> bool { + let id = &provider.instance_id; + let overridden = project + .and_then(|project| self.settings.project_settings_overrides.get(project)) + .and_then(|overrides| overrides.provider_instances.get(id)) + .and_then(|instance| instance.enabled); + if let Some(enabled) = overridden { + return enabled; + } + match self.settings.provider_instances.get(id) { + Some(config) => { + let driver = config.driver.as_deref().unwrap_or(&provider.driver); + config_enabled(config, driver) + } + None => id == "codex" || id == "claudeAgent", + } + } + + /// The entry a fresh draft starts on, with the stored selection that chose it. + fn default_entry<'a>( + &'a self, + entries: &'a [Entry<'a>], + project: Option<&str>, + ) -> Option<(&'a Entry<'a>, Option<&'a Selection>)> { + let project_selection = project + .and_then(|project| self.settings.project_settings_overrides.get(project)) + .and_then(|overrides| overrides.default_model_selection.as_ref()) + .filter(|selection| { + entries.iter().any(|entry| { + Some(entry.provider.instance_id.as_str()) == selection.instance() + && entry.enabled + }) + }); + let stored = project_selection.or(self.settings.default_model_selection.as_ref()); + if let Some(selection) = stored { + return selectable(entries, selection.instance()).map(|entry| (entry, Some(selection))); + } + let first_driver = entries.first().map(|entry| entry.provider.driver.as_str()); + let group: Vec<&Entry> = entries + .iter() + .filter(|entry| Some(entry.provider.driver.as_str()) == first_driver) + .collect(); + first_selectable(group.iter().copied()) + .or_else(|| first_selectable(entries.iter())) + .map(|entry| (entry, None)) + } +} + +fn config_enabled(config: &InstanceConfig, driver: &str) -> bool { + let flag = config.config["enabled"].as_bool(); + if config.enabled == Some(false) || flag == Some(false) { + return false; + } + config + .enabled + .or(flag) + .unwrap_or(!DEFAULT_ENABLED_OFF_DRIVERS.contains(&driver)) +} + +fn first_selectable<'a>( + entries: impl Iterator> + Clone, +) -> Option<&'a Entry<'a>> { + let usable = |entry: &&Entry| entry.enabled && entry.available(); + entries + .clone() + .find(|entry| usable(entry) && entry.provider.status == "ready") + .or_else(|| { + entries + .clone() + .find(|entry| usable(entry) && entry.provider.status != "error") + }) +} + +/// The requested instance when it can take a turn, else the first one that can. +fn selectable<'a>(entries: &'a [Entry<'a>], requested: Option<&str>) -> Option<&'a Entry<'a>> { + entries + .iter() + .find(|entry| { + Some(entry.provider.instance_id.as_str()) == requested + && entry.enabled + && entry.available() + }) + .or_else(|| first_selectable(entries.iter())) +} + +/// Slug, then name, then alias, each ignoring case; else the default model, else the first. +fn resolve_model<'a>(models: &'a [Model], requested: &str) -> Option<&'a Model> { + let requested = requested.trim(); + models + .iter() + .find(|model| model.slug == requested) + .or_else(|| { + models + .iter() + .find(|model| model.name.eq_ignore_ascii_case(requested)) + }) + .or_else(|| { + models.iter().find(|model| { + model + .aliases + .iter() + .any(|alias| alias.eq_ignore_ascii_case(requested)) + }) + }) + .or_else(|| models.iter().find(|model| model.is_default)) + .or_else(|| models.first()) +} + +fn default_model(provider: &Provider) -> String { + let models = &provider.models; + models + .iter() + .find(|model| model.is_default && !model.is_custom) + .or_else(|| models.iter().find(|model| !model.is_custom)) + .or_else(|| models.first()) + .map(|model| model.slug.clone()) + .unwrap_or_else(|| { + match provider.driver.as_str() { + "claudeAgent" => "claude-opus-5-5", + "cursor" => "auto", + "grok" => "grok-build", + "fx" | "kiro" | "acpRegistry" | "pi" => "default", + "opencode" => "openai/gpt-5", + _ => "", + } + .to_owned() + }) +} + +fn kind_label(driver: &str) -> String { + match driver { + "antigravity" => "Antigravity", + "codex" => "Codex", + "claudeAgent" => "Claude", + "cursor" => "Cursor", + "grok" => "Grok", + "fx" => "fx", + "kiro" => "Kiro", + "muse" => "Muse Code", + "acpRegistry" => "ACP Registry", + "pi" => "Pi", + "opencode" => "OpenCode", + other => return humanize(other), + } + .to_owned() +} + +fn humanize(slug: &str) -> String { + let mut spaced = String::new(); + let mut previous = ' '; + for ch in slug.chars() { + if previous.is_ascii_lowercase() && ch.is_ascii_uppercase() { + spaced.push(' '); + } + spaced.push(if matches!(ch, '_' | '-') { ' ' } else { ch }); + previous = ch; + } + let mut out = String::new(); + let mut word_start = true; + for ch in spaced + .split_whitespace() + .collect::>() + .join(" ") + .chars() + { + out.extend(if word_start { + ch.to_uppercase().collect::>() + } else { + vec![ch] + }); + word_start = !ch.is_alphanumeric(); + } + out +} + +fn display_name(provider: &Provider) -> String { + let named = provider.display_name.as_deref().map(str::trim); + let kind = kind_label(&provider.driver); + if let Some(name) = named.filter(|name| !name.is_empty() && *name != kind) { + return name.to_owned(); + } + if provider.instance_id != provider.driver { + let humanized = humanize(&provider.instance_id); + if !humanized.is_empty() { + return humanized; + } + } + named + .filter(|name| !name.is_empty()) + .map_or(kind, str::to_owned) +} + +fn accent(provider: &Provider) -> Option { + let color = provider.accent_color.as_deref()?.trim(); + let hex = color.strip_prefix('#')?; + if hex.len() != 6 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return None; + } + u32::from_str_radix(hex, 16).ok() +} + +/// Black or white, whichever contrasts more with `color` (WCAG relative luminance). +fn accent_foreground(color: u32) -> u32 { + let channel = |shift: u32| { + let value = f64::from((color >> shift) & 0xff) / 255.0; + if value <= 0.04045 { + value / 12.92 + } else { + ((value + 0.055) / 1.055).powf(2.4) + } + }; + let luminance = 0.2126 * channel(16) + 0.7152 * channel(8) + 0.0722 * channel(0); + if (luminance + 0.05) / 0.05 >= 1.05 / (luminance + 0.05) { + 0x000000 + } else { + 0xffffff + } +} + +fn show_badge(entries: &[Entry], entry: &Entry) -> bool { + if accent(entry.provider).is_some() { + return true; + } + entries + .iter() + .filter(|candidate| candidate.provider.driver == entry.provider.driver) + .filter(|candidate| !entry.enabled || candidate.enabled) + .count() + > 1 +} + +fn badge(provider: &Provider, display_name: &str) -> Badge { + Badge { + initials: initials(display_name), + accent: accent(provider).map(|color| (color, accent_foreground(color))), + } +} + +fn is_separator(ch: char) -> bool { + let ascii_punctuation = ch.is_ascii_punctuation() && !"$+<=>^`|~".contains(ch); + let general_punctuation = matches!(ch, '\u{2010}'..='\u{2027}' | '\u{2030}'..='\u{205e}'); + (ascii_punctuation || general_punctuation) && ch != '\'' && ch != '\u{2019}' +} + +/// First two letters of a one-word name, else the first letter of the first two words. +pub fn initials(label: &str) -> String { + let spaced: String = label + .chars() + .map(|ch| if is_separator(ch) { ' ' } else { ch }) + .collect(); + let words: Vec<&str> = spaced + .split_whitespace() + .map(|word| word.trim_matches(|ch| ch == '\'' || ch == '\u{2019}')) + .filter(|word| !word.is_empty()) + .collect(); + match words.as_slice() { + [] => String::new(), + [word] => word.chars().take(2).collect::().to_uppercase(), + [first, second, ..] => [first, second] + .iter() + .filter_map(|word| word.chars().next()) + .collect::() + .to_uppercase(), + } +} + +fn non_version_words(text: &str) -> Vec { + let mut stripped = String::new(); + let mut depth = 0; + for ch in text.chars() { + match ch { + '(' => depth += 1, + ')' if depth > 0 => { + depth -= 1; + stripped.push(' '); + } + _ if depth == 0 => stripped.push(ch), + _ => {} + } + } + stripped + .split(|ch: char| ch.is_whitespace() || ch == '-') + .filter(|word| !word.is_empty() && !is_version(word)) + .map(str::to_owned) + .collect() +} + +fn is_version(word: &str) -> bool { + let word = word.strip_prefix(['v', 'V']).unwrap_or(word); + let word = match word.strip_suffix(|ch: char| ch.is_ascii_alphabetic()) { + Some(rest) if rest.ends_with(|ch: char| ch.is_ascii_digit()) => rest, + _ => word, + }; + !word.is_empty() + && word + .split('.') + .all(|part| !part.is_empty() && part.bytes().all(|b| b.is_ascii_digit())) +} + +fn names_model(text: &str) -> bool { + non_version_words(text) + .iter() + .any(|word| !VARIANT_WORDS.contains(&word.to_lowercase().as_str())) +} + +fn native_families(driver: &str) -> &'static [&'static str] { + match driver { + "codex" => &["GPT-", "GPT "], + "claudeAgent" => &["Claude "], + "cursor" | "grok" => &["Grok "], + "antigravity" => &["Gemini "], + "muse" => &["Muse "], + _ => &[], + } +} + +/// `^[A-Z][\w.&\- ]{0,24}: (?=\S)` +fn strip_vendor_prefix(name: &str) -> &str { + let Some(colon) = name.find(": ") else { + return name; + }; + let head = &name[..colon]; + let valid = head.chars().count() <= 25 + && head.starts_with(|ch: char| ch.is_ascii_uppercase()) + && head + .chars() + .all(|ch| ch.is_alphanumeric() || matches!(ch, '_' | '.' | '&' | '-' | ' ')); + let rest = &name[colon + 2..]; + if valid && rest.starts_with(|ch: char| !ch.is_whitespace()) { + rest + } else { + name + } +} + +fn model_short_name(name: &str, native: &[&str]) -> Option { + let unprefixed = strip_vendor_prefix(name); + let without_family = FAMILY_PREFIXES + .iter() + .find(|prefix| unprefixed.starts_with(**prefix)) + .and_then(|prefix| { + let rest = unprefixed[prefix.len()..].trim(); + let keep = if native.contains(prefix) { + !non_version_words(rest).is_empty() + } else { + names_model(rest) + }; + keep.then(|| { + if rest.contains(char::is_whitespace) { + rest.to_owned() + } else { + rest.replace('-', " ") + } + }) + }); + let short = without_family.unwrap_or_else(|| { + if names_model(unprefixed) { + unprefixed.to_owned() + } else { + name.to_owned() + } + }); + (short != name).then_some(short) +} + +/// Drops a leading `qualifier` and its separator (`stripLeadingQualifier`). +fn display_model_name(name: &str, qualifier: Option<&str>) -> String { + let Some(qualifier) = qualifier + .map(str::trim) + .filter(|qualifier| !qualifier.is_empty()) + else { + return name.to_owned(); + }; + let lower_name = name.to_lowercase(); + let lower_qualifier = qualifier.to_lowercase(); + let Some(rest) = lower_name + .strip_prefix(&lower_qualifier) + .filter(|_| name.is_char_boundary(lower_qualifier.len())) + .map(|_| &name[lower_qualifier.len()..]) + else { + return name.to_owned(); + }; + let after = rest.trim_start(); + let separated = after.len() < rest.len() || after.starts_with(['.', ':', '/', '-']); + if !separated { + return name.to_owned(); + } + let stripped = after + .strip_prefix(['.', ':', '/', '-']) + .unwrap_or(after) + .trim(); + if stripped.is_empty() { + name.to_owned() + } else { + stripped.to_owned() + } +} + +fn trigger_model_name(model: &Model, driver: &str) -> String { + let short = model + .short_name + .clone() + .or_else(|| model_short_name(&model.name, native_families(driver))); + match short { + Some(short) => display_model_name(&short, model.sub_provider.as_deref()), + None => display_model_name(&model.name, model.sub_provider.as_deref()), + } +} + +fn is_reasoning(descriptor: &Descriptor) -> bool { + match descriptor { + Descriptor::Select { id, role, .. } => { + role.as_deref() == Some("reasoning") || REASONING_IDS.contains(&id.as_str()) + } + Descriptor::Boolean { id, .. } => REASONING_IDS.contains(&id.as_str()), + } +} + +fn is_speed_control(descriptor: &Descriptor, driver: &str) -> bool { + match descriptor { + Descriptor::Select { id, .. } => driver == "codex" && id == "serviceTier", + Descriptor::Boolean { id, .. } => id == "fastMode", + } +} + +/// The reasoning control's current setting, such as `High`. +fn effort_word(model: &Model, driver: &str, selection: Option<&Selection>) -> Option { + let descriptors = &model.capabilities.as_ref()?.option_descriptors; + let candidates = || { + descriptors + .iter() + .filter(|descriptor| !is_speed_control(descriptor, driver)) + }; + let reasoning = candidates() + .filter(|descriptor| matches!(descriptor, Descriptor::Select { .. })) + .chain(candidates().filter(|descriptor| matches!(descriptor, Descriptor::Boolean { .. }))) + .find(|descriptor| is_reasoning(descriptor))?; + match reasoning { + Descriptor::Select { + id, + options, + current_value, + .. + } => { + let current = selection + .and_then(|selection| selection.option(id)) + .or_else(|| current_value.clone()) + .or_else(|| { + options + .iter() + .find(|option| option.is_default) + .map(|option| option.id.clone()) + })?; + options + .iter() + .find(|option| option.id == current) + .map(|option| option.label.clone()) + } + Descriptor::Boolean { + id, + label, + current_value, + } => { + let on = selection + .and_then(|selection| selection.option(id)) + .map(|value| value == "true") + .or(*current_value) + == Some(true); + Some(format!("{label} {}", if on { "On" } else { "Off" })) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::strip::{EnvMode, RuntimeMode}; + use serde_json::json; + + fn model(slug: &str, name: &str, is_default: bool) -> Value { + json!({"slug": slug, "name": name, "isCustom": false, "isDefault": is_default, "capabilities": null}) + } + + fn provider(instance: &str, driver: &str, status: &str, models: Vec) -> Value { + json!({"instanceId": instance, "driver": driver, "status": status, "models": models}) + } + + fn catalog(providers: Vec, settings: Value) -> Catalog { + Catalog::decode(json!({"providers": providers, "settings": settings})).unwrap() + } + + #[test] + fn defaults_prefer_the_project_override() { + let catalog = catalog( + vec![], + json!({ + "defaultRuntimeMode": "auto", + "defaultThreadEnvMode": "worktree", + "projectSettingsOverrides": {"p": {"defaultThreadEnvMode": "local"}}, + }), + ); + let overridden = catalog.defaults(Some("p")); + assert_eq!(overridden.env_mode, EnvMode::Local); + assert_eq!(overridden.runtime_mode, RuntimeMode::Auto); + assert_eq!(catalog.defaults(Some("q")).env_mode, EnvMode::Worktree); + assert_eq!(catalog.defaults(None).env_mode, EnvMode::Worktree); + } + + #[test] + fn initials_follow_the_web() { + assert_eq!(initials("Codex"), "CO"); + assert_eq!(initials("Codex (hub)"), "CH"); + assert_eq!(initials("Theo's Codex"), "TC"); + assert_eq!(initials("Codex 'hub'"), "CH"); + assert_eq!(initials("work-claude personal"), "WC"); + assert_eq!(initials(" "), ""); + } + + #[test] + fn accent_foreground_contrasts() { + assert_eq!(accent_foreground(0xffffff), 0x000000); + assert_eq!(accent_foreground(0x000000), 0xffffff); + assert_eq!(accent_foreground(0x1d4ed8), 0xffffff); + assert_eq!(accent_foreground(0xfacc15), 0x000000); + } + + #[test] + fn short_names_drop_the_native_family() { + let claude = native_families("claudeAgent"); + assert_eq!( + model_short_name("Claude Sonnet 4.5", claude).as_deref(), + Some("Sonnet 4.5") + ); + assert_eq!(model_short_name("Claude 4.5", claude), None); + assert_eq!(model_short_name("GPT-5.5", native_families("codex")), None); + assert_eq!( + model_short_name("GPT-5.5-Codex", native_families("codex")).as_deref(), + Some("5.5 Codex") + ); + assert_eq!(model_short_name("GPT-5 Pro", &[]), None); + assert_eq!( + model_short_name("Anthropic: Claude Opus 5", &[]).as_deref(), + Some("Opus 5") + ); + } + + #[test] + fn qualifiers_are_stripped_from_names() { + assert_eq!( + display_model_name("OpenAI / GPT-5", Some("OpenAI")), + "GPT-5" + ); + assert_eq!(display_model_name("OpenAI GPT-5", Some("openai")), "GPT-5"); + assert_eq!(display_model_name("OpenAIs", Some("OpenAI")), "OpenAIs"); + assert_eq!(display_model_name("OpenAI", Some("OpenAI")), "OpenAI"); + } + + #[test] + fn display_names_prefer_the_snapshot_then_the_instance() { + let named = |instance: &str, display: Option<&str>| Provider { + instance_id: instance.into(), + driver: "codex".into(), + display_name: display.map(str::to_owned), + accent_color: None, + status: "ready".into(), + availability: None, + models: Vec::new(), + }; + assert_eq!(display_name(&named("codex", Some("Codex"))), "Codex"); + assert_eq!( + display_name(&named("codex_work", Some("Codex"))), + "Codex Work" + ); + assert_eq!(display_name(&named("codex_work", Some("Hub"))), "Hub"); + assert_eq!(humanize("claudeAgent"), "Claude Agent"); + } + + #[test] + fn unconfigured_instances_enable_only_codex_and_claude() { + let catalog = catalog( + vec![ + provider("codex", "codex", "ready", vec![]), + provider("cursor", "cursor", "ready", vec![]), + ], + json!({}), + ); + let enabled: Vec = catalog + .entries(None) + .iter() + .map(|entry| entry.enabled) + .collect(); + assert_eq!(enabled, [true, false]); + } + + #[test] + fn configured_instances_follow_driver_defaults_and_flags() { + let config = |value: Value| serde_json::from_value::(value).unwrap(); + assert!(config_enabled(&config(json!({"driver": "codex"})), "codex")); + assert!(!config_enabled( + &config(json!({"driver": "cursor"})), + "cursor" + )); + assert!(config_enabled(&config(json!({"enabled": true})), "cursor")); + assert!(!config_enabled( + &config(json!({"enabled": true, "config": {"enabled": false}})), + "codex" + )); + assert!(config_enabled( + &config(json!({"config": {"enabled": true}})), + "cursor" + )); + } + + #[test] + fn project_override_beats_instance_config() { + let catalog = catalog( + vec![provider("codex", "codex", "ready", vec![])], + json!({"projectSettingsOverrides": {"p1": {"providerInstances": {"codex": {"enabled": false}}}}}), + ); + assert!(catalog.entries(Some("p2"))[0].enabled); + assert!(!catalog.entries(Some("p1"))[0].enabled); + } + + #[test] + fn entries_group_by_driver_with_defaults_first() { + let catalog = catalog( + vec![ + provider("codex_b", "codex", "ready", vec![]), + provider("claudeAgent", "claudeAgent", "ready", vec![]), + provider("codex", "codex", "ready", vec![]), + ], + json!({}), + ); + let ids: Vec = catalog + .entries(None) + .iter() + .map(|entry| entry.provider.instance_id.clone()) + .collect(); + assert_eq!(ids, ["codex", "codex_b", "claudeAgent"]); + } + + #[test] + fn selection_falls_back_to_a_ready_instance() { + let catalog = catalog( + vec![ + provider("codex", "codex", "error", vec![model("a", "A", true)]), + provider( + "claudeAgent", + "claudeAgent", + "ready", + vec![model("b", "B", true)], + ), + ], + json!({}), + ); + assert_eq!(catalog.trigger(None).unwrap().driver, "claudeAgent"); + } + + #[test] + fn unavailable_instances_are_skipped() { + let mut unavailable = provider("codex", "codex", "ready", vec![model("a", "A", true)]); + unavailable["availability"] = json!("unavailable"); + let catalog = catalog( + vec![ + unavailable, + provider( + "claudeAgent", + "claudeAgent", + "warning", + vec![model("b", "B", true)], + ), + ], + json!({}), + ); + assert_eq!(catalog.trigger(None).unwrap().driver, "claudeAgent"); + } + + #[test] + fn stored_default_selection_wins_and_resolves_the_model() { + let models = vec![ + model("gpt-a", "GPT A", true), + model("gpt-b", "GPT B", false), + ]; + let catalog = catalog( + vec![ + provider( + "claudeAgent", + "claudeAgent", + "ready", + vec![model("c", "C", true)], + ), + provider("codex", "codex", "ready", models), + ], + json!({"defaultModelSelection": {"instanceId": "codex", "model": "gpt b"}}), + ); + let trigger = catalog.trigger(None).unwrap(); + assert_eq!(trigger.driver, "codex"); + assert_eq!(trigger.title, "GPT B"); + } + + #[test] + fn project_default_applies_only_when_its_instance_is_enabled() { + let providers = vec![ + provider("codex", "codex", "ready", vec![model("a", "A", true)]), + provider( + "claudeAgent", + "claudeAgent", + "ready", + vec![model("b", "B", true)], + ), + ]; + let enabled = catalog( + providers.clone(), + json!({"projectSettingsOverrides": {"p": {"defaultModelSelection": {"instanceId": "claudeAgent", "model": "b"}}}}), + ); + assert_eq!(enabled.trigger(Some("p")).unwrap().driver, "claudeAgent"); + assert_eq!(enabled.trigger(None).unwrap().driver, "codex"); + let disabled = catalog( + providers, + json!({"projectSettingsOverrides": {"p": { + "defaultModelSelection": {"instanceId": "claudeAgent", "model": "b"}, + "providerInstances": {"claudeAgent": {"enabled": false}} + }}}), + ); + assert_eq!(disabled.trigger(Some("p")).unwrap().driver, "codex"); + } + + #[test] + fn default_model_prefers_flagged_then_first_non_custom() { + let mut custom = model("custom", "Custom", true); + custom["isCustom"] = json!(true); + let provider_of = |models: Vec| { + catalog(vec![provider("codex", "codex", "ready", models)], json!({})) + .providers + .remove(0) + }; + assert_eq!( + default_model(&provider_of(vec![ + custom.clone(), + model("a", "A", false), + model("b", "B", true) + ])), + "b" + ); + assert_eq!( + default_model(&provider_of(vec![custom, model("a", "A", false)])), + "a" + ); + let cursor = catalog( + vec![provider("cursor", "cursor", "ready", vec![])], + json!({}), + ) + .providers + .remove(0); + assert_eq!(default_model(&cursor), "auto"); + } + + #[test] + fn badge_shows_for_accents_and_sibling_instances() { + let mut accented = provider("codex", "codex", "ready", vec![]); + accented["accentColor"] = json!("#1d4ed8"); + let alone = catalog(vec![accented], json!({})); + let entries = alone.entries(None); + assert!(show_badge(&entries, &entries[0])); + let plain = catalog(vec![provider("codex", "codex", "ready", vec![])], json!({})); + let entries = plain.entries(None); + assert!(!show_badge(&entries, &entries[0])); + let siblings = catalog( + vec![ + provider("codex", "codex", "ready", vec![]), + provider("codex_work", "codex", "ready", vec![]), + ], + json!({"providerInstances": {"codex_work": {"driver": "codex", "enabled": true}}}), + ); + let entries = siblings.entries(None); + assert!(show_badge(&entries, &entries[0])); + assert_eq!(badge(entries[1].provider, "Codex Work").initials, "CW"); + } + + #[test] + fn effort_reads_the_reasoning_select() { + let mut with_effort = model("m", "M", true); + with_effort["capabilities"] = json!({"optionDescriptors": [ + {"id": "serviceTier", "label": "Speed", "type": "select", "options": [{"id": "fast", "label": "Fast"}], "currentValue": "fast"}, + {"id": "reasoningEffort", "label": "Reasoning", "type": "select", + "options": [{"id": "low", "label": "Low"}, {"id": "high", "label": "High", "isDefault": true}]}, + {"id": "fastMode", "label": "Fast", "type": "boolean", "currentValue": true} + ]}); + let catalog = catalog( + vec![provider("codex", "codex", "ready", vec![with_effort])], + json!({}), + ); + assert_eq!( + catalog.trigger(None).unwrap().effort.as_deref(), + Some("High") + ); + let chosen = + catalog_with_selection(&catalog, json!([{"id": "reasoningEffort", "value": "low"}])); + assert_eq!(chosen.as_deref(), Some("Low")); + } + + fn catalog_with_selection(catalog: &Catalog, options: Value) -> Option { + let selection: Selection = serde_json::from_value( + json!({"instanceId": "codex", "model": "m", "options": options}), + ) + .unwrap(); + effort_word(&catalog.providers[0].models[0], "codex", Some(&selection)) + } + + #[test] + fn boolean_reasoning_reads_on_or_off() { + let mut thinking = model("m", "M", true); + thinking["capabilities"] = json!({"optionDescriptors": [ + {"id": "thinking", "label": "Thinking", "type": "boolean", "currentValue": true} + ]}); + let catalog = catalog( + vec![provider( + "claudeAgent", + "claudeAgent", + "ready", + vec![thinking], + )], + json!({}), + ); + assert_eq!( + catalog.trigger(None).unwrap().effort.as_deref(), + Some("Thinking On") + ); + } + + #[test] + fn malformed_entries_are_dropped_not_fatal() { + let catalog = catalog( + vec![ + json!({"instanceId": 3}), + provider( + "codex", + "codex", + "ready", + vec![json!("junk"), model("a", "A", true)], + ), + ], + json!({}), + ); + assert_eq!(catalog.providers.len(), 1); + assert_eq!(catalog.providers[0].models.len(), 1); + } + + #[test] + fn no_selectable_provider_means_no_trigger() { + assert_eq!( + catalog( + vec![provider("cursor", "cursor", "ready", vec![])], + json!({}) + ) + .trigger(None), + None + ); + } +} diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs index f7bcc189ed91..ee6313eb4c18 100644 --- a/apps/desktop-gpui/src/rpc.rs +++ b/apps/desktop-gpui/src/rpc.rs @@ -35,6 +35,24 @@ impl Method for ProjectsMutate { type Success = wire::ProjectsMutateSuccess; } +/// `server.getConfig`, a unary call. The wire types are not generated; `providers::Catalog` decodes it. +pub struct ServerGetConfig; + +impl Method for ServerGetConfig { + const TAG: &'static str = "server.getConfig"; + type Payload = Value; + type Success = Value; +} + +/// `vcs.listRefs`, a unary call. The wire types are not generated; `strip::Branch` decodes it. +pub struct VcsListRefs; + +impl Method for VcsListRefs { + const TAG: &'static str = "vcs.listRefs"; + type Payload = Value; + type Success = Value; +} + /// Request ids are bigints on the wire, so they travel as strings. fn id_text<'de, D: Deserializer<'de>>(deserializer: D) -> Result { match Value::deserialize(deserializer)? { diff --git a/apps/desktop-gpui/src/shell.rs b/apps/desktop-gpui/src/shell.rs index 15aa77c499bb..ef9e9942c5fd 100644 --- a/apps/desktop-gpui/src/shell.rs +++ b/apps/desktop-gpui/src/shell.rs @@ -2,13 +2,14 @@ //! `packages/client-runtime/src/state/shellReducer.ts` for what this client shows. use crate::projects; -use crate::status::{Section, ShellView, ThreadRow}; +use crate::status::{PrBadge, PrState, Section, ShellView, ThreadRow, TopStatus}; use crate::wire::generated::{ OrchestrationSubscribeShellSuccess as Item, OrchestrationSubscribeShellSuccessVariant4Location as Location, OrchestrationSubscribeShellSuccessVariant5Location as RemovedLocation, }; use crate::wire::generated::{ + PullRequestsItem, PullRequestsItemSource as LinkSource, SnapshotState as PrSnapshotState, ThreadsEntryActivityRunStatus as Activity, ThreadsEntryInteractionMode as InteractionMode, ThreadsEntryPendingRuntimeRequestKind as RequestKind, ThreadsEntrySettledOverride as Override, ThreadsEntryStatus as RunStatus, @@ -278,6 +279,68 @@ fn sort_settled(threads: &mut [&Thread]) { threads.sort_by(|a, b| newest_first((anchor(a), a), (anchor(b), b))); } +/// `resolveThreadPullRequestBadge` with the presentation of `ThreadStatusIndicators`. A stack is +/// the links of one native stack; stacks the web derives from head and base branches show as `+N`. +fn pr_badge(thread: &Thread) -> Option { + let visible: Vec<_> = thread + .pull_requests + .iter() + .flat_map(|links| &links.0) + .filter(|link| link.source != LinkSource::StackDismissed) + .collect(); + if visible.is_empty() { + let linked = thread.linked_pull_request.as_ref()?; + return Some(PrBadge { + state: PrState::Pending, + stack: false, + text: linked.number.to_string(), + }); + } + let state_of = |link: &&PullRequestsItem| match link.snapshot.as_ref() { + None => PrState::Open, + Some(snapshot) => match snapshot.state { + PrSnapshotState::Open if snapshot.is_draft => PrState::Draft, + PrSnapshotState::Open => PrState::Open, + PrSnapshotState::Closed => PrState::Closed, + PrSnapshotState::Merged => PrState::Merged, + }, + }; + if let [only] = visible.as_slice() { + return Some(PrBadge { + state: only + .snapshot + .as_ref() + .map_or(PrState::Pending, |_| state_of(&only)), + stack: false, + text: only.number.to_string(), + }); + } + let states: Vec = visible.iter().map(state_of).collect(); + let state = if states.iter().all(|state| *state == PrState::Draft) { + PrState::Draft + } else if states.contains(&PrState::Open) { + PrState::Open + } else if states.iter().all(|state| *state == PrState::Merged) { + PrState::Merged + } else { + PrState::Closed + }; + let stack_id = visible[0].stack.as_ref().map(|stack| &stack.id); + let stack = stack_id.is_some() + && visible + .iter() + .all(|link| link.stack.as_ref().map(|stack| &stack.id) == stack_id); + Some(PrBadge { + state, + stack, + text: if stack { + visible.len().to_string() + } else { + format!("+{}", visible.len()) + }, + }) +} + fn now_ms() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) @@ -336,7 +399,17 @@ impl ShellState { .unwrap_or_default(), title: thread.title.clone(), branch: thread.branch.clone(), - status: thread.status, + worktree: thread + .worktree_path + .as_deref() + .is_some_and(|path| !path.trim().is_empty()), + provider_instance: thread.provider_instance_id.clone(), + top: TopStatus::of( + thread.status, + thread.blocked_on_thread_wait == Some(true), + thread.last_error_class, + ), + pr: pr_badge(thread), section, }; let rows = SECTION_ORDER @@ -523,6 +596,46 @@ mod tests { } } + #[test] + fn the_pull_request_badge_follows_the_web() { + let link = |number: u64, source: &str, state: Option<&str>| { + let mut link = serde_json::json!({ + "host": "github.com", "linkedAt": "2026-01-01T00:00:00.000Z", + "number": number, "repository": "o/r", "source": source, + "url": format!("https://github.com/o/r/pull/{number}"), + "snapshot": null, "stack": null, + }); + if let Some(state) = state { + link["snapshot"] = serde_json::json!({ + "baseBranch": "main", "headBranch": "h", "isDraft": false, "state": state, + "syncedAt": "2026-01-01T00:00:00.000Z", "title": "t", "updatedAt": null, + }); + } + link + }; + let badge = |links: Vec| { + pr_badge(&thread("t", |t| set(t, &[("pullRequests", links.into())]))) + .map(|badge| (badge.state, badge.text)) + }; + assert_eq!(badge(vec![]), None); + assert_eq!( + badge(vec![link(7, "manual", None)]), + Some((PrState::Pending, "7".to_owned())) + ); + assert_eq!( + badge(vec![link(7, "manual", Some("merged"))]), + Some((PrState::Merged, "7".to_owned())) + ); + assert_eq!( + badge(vec![ + link(1, "manual", Some("merged")), + link(2, "agent", Some("open")), + link(3, "stack-dismissed", Some("closed")), + ]), + Some((PrState::Open, "+2".to_owned())) + ); + } + #[test] fn timestamps_parse_like_date_parse() { for (text, expected) in [ diff --git a/apps/desktop-gpui/src/stage-dev-button.svg b/apps/desktop-gpui/src/stage-dev-button.svg new file mode 100644 index 000000000000..1210577b677f --- /dev/null +++ b/apps/desktop-gpui/src/stage-dev-button.svg @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop-gpui/src/stage-dev.svg b/apps/desktop-gpui/src/stage-dev.svg new file mode 100644 index 000000000000..3b36247ceba7 --- /dev/null +++ b/apps/desktop-gpui/src/stage-dev.svg @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index 4199193a84f6..7c98c2bf8c0f 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -1,5 +1,7 @@ -use crate::projects::{EnvironmentProjects, ProjectEntry}; -use crate::wire::generated::ThreadsEntryStatus; +use crate::projects::{EnvironmentProjects, MachineKind, ProjectEntry}; +use crate::providers::Catalog; +use crate::strip::Branch; +use crate::wire::generated::{ThreadsEntryLastErrorClass, ThreadsEntryStatus}; use std::fmt; /// What the window shows about the managed server and its socket. @@ -11,6 +13,7 @@ pub enum Status { /// Socket is up. `shell` stays `None` until the first shell snapshot lands. Connected { label: String, + machine: MachineKind, /// The server keeps project pins, so the window offers them. can_pin: bool, shell: Option, @@ -18,6 +21,13 @@ pub enum Status { Failed(String), /// Another saved environment's projects; says nothing about the primary connection. Environment(EnvironmentProjects), + /// The primary connection's provider instances, for the composer's model picker. + Providers(Catalog), + /// The branch checked out in a workspace root; `None` when it is not a repository. + Branch { + root: String, + branch: Option, + }, } /// Where the web sidebar lists a thread. @@ -48,10 +58,72 @@ pub struct ThreadRow { pub project: String, pub title: String, pub branch: Option, - pub status: ThreadsEntryStatus, + pub worktree: bool, + pub provider_instance: String, + pub top: Option, + pub pr: Option, pub section: Section, } +/// A pull request's state as the badge tones it; `Pending` has no snapshot yet. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum PrState { + Pending, + Open, + Draft, + Closed, + Merged, +} + +/// The web's `ThreadPullRequestBadgePresentation`: the number, `+N` for several, or the layer +/// count of a stack. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PrBadge { + pub state: PrState, + pub stack: bool, + pub text: String, +} + +/// The status the web's thread card shows at the top right (`topStatus` in `SidebarThreadRow`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TopStatus { + Working, + Waiting, + Limited, + Failed, +} + +impl TopStatus { + pub fn label(self) -> &'static str { + match self { + Self::Working => "Working", + Self::Waiting => "Waiting", + Self::Limited => "Limited", + Self::Failed => "Failed", + } + } + + /// `resolveSidebarThreadStatus` without approvals, input and guest requests, which the row + /// does not carry yet. + pub fn of( + status: ThreadsEntryStatus, + blocked_on_thread_wait: bool, + last_error_class: Option, + ) -> Option { + use ThreadsEntryStatus as S; + match status { + S::Running if blocked_on_thread_wait => Some(Self::Waiting), + S::Preparing | S::Queued | S::Starting | S::Running | S::Waiting => Some(Self::Working), + S::Idle => Some(Self::Waiting), + S::Failed if last_error_class == Some(ThreadsEntryLastErrorClass::UsageLimit) => { + Some(Self::Limited) + } + S::Failed => Some(Self::Failed), + _ => None, + } + } +} + fn plural(count: usize, noun: &str) -> String { if count == 1 { noun.to_owned() @@ -83,6 +155,8 @@ impl fmt::Display for Status { ), Self::Failed(message) => f.write_str(message), Self::Environment(other) => write!(f, "Environment {}", other.label), + Self::Providers(_) => f.write_str("Providers"), + Self::Branch { root, .. } => write!(f, "Branch of {root}"), } } } @@ -98,17 +172,49 @@ pub fn publish(sender: &StatusSender, status: Status) { mod tests { use super::*; + #[test] + fn the_top_status_follows_the_web() { + let of = |status, blocked, class| TopStatus::of(status, blocked, class); + assert_eq!( + of(ThreadsEntryStatus::Running, false, None), + Some(TopStatus::Working) + ); + assert_eq!( + of(ThreadsEntryStatus::Running, true, None), + Some(TopStatus::Waiting) + ); + assert_eq!( + of(ThreadsEntryStatus::Idle, false, None), + Some(TopStatus::Waiting) + ); + assert_eq!( + of( + ThreadsEntryStatus::Failed, + false, + Some(ThreadsEntryLastErrorClass::UsageLimit) + ), + Some(TopStatus::Limited) + ); + assert_eq!( + of(ThreadsEntryStatus::Failed, false, None), + Some(TopStatus::Failed) + ); + assert_eq!(of(ThreadsEntryStatus::Completed, false, None), None); + } + #[test] fn connected_text_includes_counts_once_known() { let label = "Local".to_owned(); let waiting = Status::Connected { label: label.clone(), + machine: MachineKind::Server, can_pin: false, shell: None, }; assert_eq!(waiting.to_string(), "Connected to Local"); let known = Status::Connected { label: label.clone(), + machine: MachineKind::Server, can_pin: false, shell: Some(ShellView { projects: 2, @@ -122,6 +228,7 @@ mod tests { ); let single = Status::Connected { label, + machine: MachineKind::Server, can_pin: false, shell: Some(ShellView { projects: 1, diff --git a/apps/desktop-gpui/src/strip.rs b/apps/desktop-gpui/src/strip.rs new file mode 100644 index 000000000000..878803617df6 --- /dev/null +++ b/apps/desktop-gpui/src/strip.rs @@ -0,0 +1,279 @@ +//! What the strip under the composer shows: the environment, workspace, branch and permissions +//! controls (`BranchToolbar.logic.ts`, `BranchToolbarBranchSelector.tsx`, `runtimeModeConfig.ts`). +//! The branch comes from `vcs.listRefs`, decoded by hand like `server.getConfig`. + +use serde_json::Value; + +/// The shortcut half of a tooltip, the web's `shortcutLabelForCommand` for the default bindings on macOS. +pub const HOST_SHORTCUT: &str = "\u{21e7}\u{2318}H"; +pub const WORKSPACE_SHORTCUT: &str = "\u{21e7}\u{2318}X"; +pub const PERMISSIONS_SHORTCUT: &str = "\u{21e7}\u{2318}A"; + +/// The refs listed to find the checked-out branch. +pub const REFS_LIMIT: u32 = 100; + +const GENERIC_LOCAL_LABELS: [&str; 2] = ["local", "local environment"]; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum EnvMode { + #[default] + Local, + Worktree, +} + +impl EnvMode { + fn parse(text: &str) -> Option { + match text { + "local" => Some(Self::Local), + "worktree" => Some(Self::Worktree), + _ => None, + } + } + + /// `resolveEnvModeLabel`. + pub fn label(self) -> &'static str { + match self { + Self::Local => "Current checkout", + Self::Worktree => "New worktree", + } + } +} + +/// `RuntimeMode` (packages/contracts/src/providerPolicy.ts). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RuntimeMode { + ApprovalRequired, + AutoAcceptEdits, + Auto, + FullAccess, +} + +impl RuntimeMode { + fn parse(text: &str) -> Option { + match text { + "approval-required" => Some(Self::ApprovalRequired), + "auto-accept-edits" => Some(Self::AutoAcceptEdits), + "auto" => Some(Self::Auto), + "full-access" => Some(Self::FullAccess), + _ => None, + } + } + + /// `runtimeModeOptions`, the order of the permissions toggle. + pub const ALL: [Self; 4] = [ + Self::ApprovalRequired, + Self::AutoAcceptEdits, + Self::Auto, + Self::FullAccess, + ]; + + /// `runtimeModeConfig`. + pub fn description(self) -> &'static str { + match self { + Self::ApprovalRequired => "Ask before commands and file changes.", + Self::AutoAcceptEdits => "Auto-approve edits, ask before other actions.", + Self::Auto => "Supported providers approve routine actions; others still ask.", + Self::FullAccess => "Allow commands and edits without prompts.", + } + } + + /// `runtimeModeConfig`. + pub fn label(self) -> &'static str { + match self { + Self::ApprovalRequired => "Supervised", + Self::AutoAcceptEdits => "Auto-accept edits", + Self::Auto => "Auto", + Self::FullAccess => "Full access", + } + } +} + +/// The mode the permissions popover names, and whether it is the selected one, as hover previews. +pub fn previewed(selected: RuntimeMode, preview: Option) -> (RuntimeMode, bool) { + let shown = preview.unwrap_or(selected); + (shown, shown == selected) +} + +/// What a fresh draft starts with, from the settings of the environment. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Defaults { + pub env_mode: EnvMode, + pub runtime_mode: RuntimeMode, + pub start_from_origin: bool, +} + +impl Defaults { + /// Unset or unknown values take the schema defaults (settings.ts): a local checkout, full + /// access, and new worktrees starting from origin. The project's `t3.json` is not read. + pub fn resolve( + env_mode: Option<&str>, + runtime_mode: Option<&str>, + start_from_origin: Option, + ) -> Self { + Self { + env_mode: env_mode.and_then(EnvMode::parse).unwrap_or_default(), + runtime_mode: runtime_mode + .and_then(RuntimeMode::parse) + .unwrap_or(RuntimeMode::FullAccess), + start_from_origin: start_from_origin.unwrap_or(true), + } + } +} + +/// The branch a project's checkout is on. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Branch { + pub name: Option, + /// Whether the listed ref is a remote one; `None` when it was not listed. + pub is_remote: Option, +} + +impl Branch { + /// The `vcs.listRefs` result; `None` when no ref is listed, as in a folder that is not a repository. + pub fn decode(result: &Value) -> Option { + let refs = result["refs"].as_array().filter(|refs| !refs.is_empty())?; + let current = refs.iter().find(|r| r["current"] == true); + Some(Self { + name: current.and_then(|r| r["name"].as_str()).map(str::to_owned), + is_remote: current.map(|r| r["isRemote"] == true), + }) + } + + /// `resolveBranchTriggerLabel` for a draft that has no worktree yet. + pub fn label(&self, env_mode: EnvMode, start_from_origin: bool) -> String { + let Some(name) = &self.name else { + return "Select ref".to_owned(); + }; + if env_mode != EnvMode::Worktree { + return name.clone(); + } + // Only the server can determine a local branch's configured remote. + if start_from_origin && self.is_remote == Some(false) { + format!("From {name} (remote)") + } else { + format!("From {name}") + } + } +} + +/// `resolveEnvironmentOptionLabel` for the primary environment: a generic name reads as "This device". +pub fn environment_label(label: &str) -> String { + let label = label.trim(); + if label.is_empty() || GENERIC_LOCAL_LABELS.contains(&label.to_lowercase().as_str()) { + "This device".to_owned() + } else { + label.to_owned() + } +} + +/// A tooltip as the web writes it: the label, then the shortcut after a middle dot. +pub fn tooltip_text(label: &str, shortcut: Option<&str>) -> String { + match shortcut { + Some(shortcut) => format!("{label} \u{b7} {shortcut}"), + None => label.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn branch(name: Option<&str>, is_remote: Option) -> Branch { + Branch { + name: name.map(str::to_owned), + is_remote, + } + } + + #[test] + fn branch_label_names_the_base_of_a_new_worktree() { + let main = branch(Some("main"), Some(false)); + assert_eq!(main.label(EnvMode::Worktree, true), "From main (remote)"); + assert_eq!(main.label(EnvMode::Worktree, false), "From main"); + assert_eq!(main.label(EnvMode::Local, true), "main"); + let remote = branch(Some("origin/main"), Some(true)); + assert_eq!(remote.label(EnvMode::Worktree, true), "From origin/main"); + let unlisted = branch(Some("main"), None); + assert_eq!(unlisted.label(EnvMode::Worktree, true), "From main"); + assert_eq!( + branch(None, None).label(EnvMode::Worktree, true), + "Select ref" + ); + } + + #[test] + fn decode_reads_the_checked_out_ref() { + let result = json!({"refs": [ + {"name": "dev", "current": false, "isDefault": false}, + {"name": "main", "current": true, "isDefault": true, "isRemote": false}, + ]}); + assert_eq!( + Branch::decode(&result), + Some(branch(Some("main"), Some(false))) + ); + let detached = json!({"refs": [{"name": "dev", "current": false}]}); + assert_eq!(Branch::decode(&detached), Some(branch(None, None))); + assert_eq!(Branch::decode(&json!({"refs": []})), None); + assert_eq!(Branch::decode(&json!({})), None); + } + + #[test] + fn defaults_fall_back_to_the_schema_values() { + let unset = Defaults::resolve(None, None, None); + assert_eq!(unset.env_mode, EnvMode::Local); + assert_eq!(unset.runtime_mode, RuntimeMode::FullAccess); + assert!(unset.start_from_origin); + let set = Defaults::resolve(Some("worktree"), Some("approval-required"), Some(false)); + assert_eq!(set.env_mode, EnvMode::Worktree); + assert_eq!(set.runtime_mode, RuntimeMode::ApprovalRequired); + assert!(!set.start_from_origin); + let unknown = Defaults::resolve(Some("cloud"), Some("yolo"), None); + assert_eq!(unknown.env_mode, EnvMode::Local); + assert_eq!(unknown.runtime_mode, RuntimeMode::FullAccess); + } + + #[test] + fn previews_name_the_hovered_mode_until_the_pointer_leaves() { + let selected = RuntimeMode::FullAccess; + assert_eq!(previewed(selected, None), (selected, true)); + assert_eq!(previewed(selected, Some(selected)), (selected, true)); + assert_eq!( + previewed(selected, Some(RuntimeMode::Auto)), + (RuntimeMode::Auto, false) + ); + assert_eq!(RuntimeMode::ALL[0], RuntimeMode::ApprovalRequired); + assert_eq!(RuntimeMode::ALL[3], selected); + assert_eq!( + RuntimeMode::Auto.description(), + "Supported providers approve routine actions; others still ask." + ); + } + + #[test] + fn labels_follow_the_web() { + assert_eq!(EnvMode::Worktree.label(), "New worktree"); + assert_eq!(EnvMode::Local.label(), "Current checkout"); + assert_eq!(RuntimeMode::FullAccess.label(), "Full access"); + assert_eq!(RuntimeMode::ApprovalRequired.label(), "Supervised"); + assert_eq!(RuntimeMode::AutoAcceptEdits.label(), "Auto-accept edits"); + assert_eq!(RuntimeMode::Auto.label(), "Auto"); + } + + #[test] + fn generic_environment_names_read_as_this_device() { + assert_eq!(environment_label("Local"), "This device"); + assert_eq!(environment_label(" local environment "), "This device"); + assert_eq!(environment_label(""), "This device"); + assert_eq!(environment_label("Ada's Mac Studio"), "Ada's Mac Studio"); + } + + #[test] + fn tooltips_put_the_shortcut_after_a_middle_dot() { + assert_eq!( + tooltip_text("New worktree", Some(WORKSPACE_SHORTCUT)), + "New worktree \u{b7} \u{21e7}\u{2318}X" + ); + assert_eq!(tooltip_text("From main", None), "From main"); + } +} diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 1822c899b501..73cf90846a40 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -1,12 +1,18 @@ -use crate::assets::MESSAGE_SQUARE_DASHED; +use crate::assets::{MESSAGE_SQUARE_DASHED, STAGE_DEV, STAGE_DEV_BUTTON, provider_icon}; use crate::connection::{Action, ActionSender}; +use crate::history::History; use crate::projects::{self, EnvironmentProjects, MachineKind, ProjectEntry}; -use crate::status::{Section, ShellView, Status, ThreadRow}; -use crate::wire::generated::ThreadsEntryStatus as ThreadStatus; +use crate::providers::{Catalog, Trigger}; +use crate::status::{PrBadge, PrState, Section, ShellView, Status, ThreadRow, TopStatus}; +use crate::strip::{ + self, Branch, Defaults, EnvMode, HOST_SHORTCUT, PERMISSIONS_SHORTCUT, RuntimeMode, + WORKSPACE_SHORTCUT, +}; use gpui::{ - Anchor, AnyElement, App, Bounds, Context, Entity, Focusable as _, FontWeight, Hsla, - IntoElement, Keystroke, ParentElement, Pixels, Render, RenderOnce, Styled, - UniformListScrollHandle, Window, canvas, div, prelude::*, px, rgb, uniform_list, + Anchor, Animation, AnimationExt as _, AnyElement, App, Bounds, Context, Entity, Focusable as _, + FontWeight, Hsla, IntoElement, Keystroke, ObjectFit, ParentElement, Pixels, Render, RenderOnce, + Styled, UniformListScrollHandle, Window, actions, canvas, div, ease_in_out, img, prelude::*, + px, rgb, uniform_list, }; use gpui_component::{ ActiveTheme, Icon, IconName, Selectable, Sizable, TitleBar, @@ -15,14 +21,72 @@ use gpui_component::{ input::{Input, InputEvent, InputState, Textarea, TextareaState}, kbd::Kbd, popover::Popover, - tag::Tag, tooltip::Tooltip, v_flex, }; -use std::{cell::Cell, rc::Rc}; +use gpui_kit_assets::IconName as Icons; +use std::{ + cell::Cell, + collections::{HashMap, HashSet}, + rc::Rc, + time::Duration, +}; const SIDEBAR_WIDTH: f32 = 300.; -const ROW_HEIGHT: f32 = 68.; +/// The web's `--workspace-topbar-height` on macOS desktop. +const TOPBAR_HEIGHT: f32 = 52.; +/// Clears the traffic lights, like `--workspace-controls-left` from `titlebar-area-x`. +const TITLEBAR_CONTENT_LEFT: f32 = 84.; +/// The web shows the dev stage art in dev builds (`APP_STAGE_LABEL` is "Dev"). +const STAGE_BACKDROP: bool = cfg!(debug_assertions); +/// `SidebarStageBackdrop`'s `h-20`. +const STAGE_BACKDROP_HEIGHT: f32 = 80.; + +actions!( + t3, + [ + ToggleSidebar, + DismissSidebar, + OpenPermissions, + GoBack, + GoForward + ] +); + +/// The web's `SIDEBAR_MOBILE_QUERY`, `(max-width: 767px)`. +const SIDEBAR_BREAKPOINT: f32 = 767.; +/// The left sheet's `w-[calc(100%-(--spacing(12)))] max-w-md`. +const SIDEBAR_OVERLAY_GAP: f32 = 48.; +const SIDEBAR_OVERLAY_MAX: f32 = 448.; +/// The sheet's `duration-200` and its `-translate-x-8` entrance. +const SHEET_MS: u64 = 200; +const SHEET_SLIDE: f32 = 32.; +/// Key context while the overlay is open, so Escape only dismisses it then. +pub const OVERLAY_CONTEXT: &str = "SidebarOverlay"; + +fn is_narrow(width: f32) -> bool { + width <= SIDEBAR_BREAKPOINT +} + +fn narrow_window(window: &Window) -> bool { + is_narrow(f32::from(window.viewport_size().width)) +} + +fn overlay_width(window_width: f32) -> f32 { + (window_width - SIDEBAR_OVERLAY_GAP).min(SIDEBAR_OVERLAY_MAX) +} + +/// The web's `toggleSidebar`: narrow viewports flip the overlay, wide ones the persistent state. +fn toggled(narrow: bool, open: bool, overlay_open: bool) -> (bool, bool) { + if narrow { + (open, !overlay_open) + } else { + (!open, overlay_open) + } +} +/// A thread card (`h-[4.875rem]`) between the `py-0.5` of its list item. +const CARD_HEIGHT: f32 = 78.; +const ROW_HEIGHT: f32 = CARD_HEIGHT + 4.; const NO_PROJECT: &str = "No project"; const HEADLINE_SCRATCH: &str = "What should we work on?"; /// The web's `ComboboxPopup` cap, `max-h-[min(var(--available-height),23rem)]`. @@ -40,13 +104,31 @@ pub struct MainView { list: UniformListScrollHandle, /// Working, Snoozed and Settled shelves; the web starts all of them collapsed. shelves: [bool; 3], + sidebar_open: bool, + sidebar_overlay_open: bool, + /// The sidebar's width at the last render, for the cards' branch truncation. + sidebar_width: Cell, + /// Hover styles cannot change layout, so the send button's hover growth is view state. + send_hovered: bool, + permissions_open: bool, + /// The open thread; selecting is all a row does yet. + selected_thread: Option, + /// Local view state until the draft's mode is sent to the server. + runtime_mode: Option, + permissions: Entity, selected_project: Option, + history: History, picker_open: bool, /// Last painted trigger bounds, so the popup can shrink to fit below it as on the web. picker_trigger: Rc>>, picker_order: Option>, /// The other saved environments, by id. environments: Vec, + providers: Option, + /// The branch per workspace root; `None` for a root that is not a repository. + branches: HashMap>, + /// Roots whose branch was asked for on this connection. + branches_requested: HashSet, actions: ActionSender, } @@ -65,22 +147,49 @@ impl MainView { } }) .detach(); + let composer = cx.new(|cx| { + TextareaState::new(window, cx) + .placeholder("Ask anything. @ files, $ skills, / commands") + // The web's `min-h-[2lh]` and `max-h-52`. + .auto_grow(2, 9) + }); + // The send button enables with content. + cx.subscribe(&composer, |_, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }) + .detach(); + let view = cx.weak_entity(); + let permissions = cx.new(|_| PermissionsPanel { + view, + selected: RuntimeMode::FullAccess, + preview: None, + }); Self { status, - search: cx.new(|cx| InputState::new(window, cx).placeholder("Search threads")), + search: cx.new(|cx| InputState::new(window, cx).placeholder("Search")), project_search, - composer: cx.new(|cx| { - TextareaState::new(window, cx) - .placeholder("Ask anything. @ files, $ skills, / commands") - .auto_grow(3, 10) - }), + composer, list: UniformListScrollHandle::new(), shelves: [false; 3], + sidebar_open: true, + sidebar_overlay_open: false, + sidebar_width: Cell::new(SIDEBAR_WIDTH), + send_hovered: false, + permissions_open: false, + selected_thread: None, + runtime_mode: None, + permissions, selected_project: None, + history: History::default(), picker_open: false, picker_trigger: Rc::default(), picker_order: None, environments: Vec::new(), + providers: None, + branches: HashMap::new(), + branches_requested: HashSet::new(), actions, } } @@ -93,6 +202,17 @@ impl MainView { } return cx.notify(); } + if let Status::Providers(catalog) = status { + self.providers = Some(catalog); + return cx.notify(); + } + if let Status::Branch { root, branch } = status { + self.branches.insert(root, branch); + return cx.notify(); + } + if matches!(status, Status::Connecting) { + self.branches_requested.clear(); + } self.status = status; // Thread activity reorders recency on every delta, so the default must not follow it. if self.selected_project.is_none() { @@ -100,10 +220,31 @@ impl MainView { .shell() .and_then(|shell| active_project(None, shell)) .map(|entry| entry.id.clone()); + if let Some(id) = &self.selected_project { + self.history.visit(id.clone()); + } } + self.request_branch(); cx.notify(); } + /// Asks for the branch of the active project's root once per connection. + fn request_branch(&mut self) { + let Some(entry) = self + .shell() + .and_then(|shell| active_project(self.selected_project.as_deref(), shell)) + else { + return; + }; + let root = entry.root.clone(); + if entry.scratch || root.is_empty() || !self.branches_requested.insert(root.clone()) { + return; + } + if self.actions.try_send(Action::LoadBranch { root }).is_err() { + eprintln!("[ui] could not ask for the branch"); + } + } + fn shell(&self) -> Option<&ShellView> { match &self.status { Status::Connected { shell, .. } => shell.as_ref(), @@ -123,64 +264,446 @@ impl MainView { Some(listed.into_iter().map(|entry| entry.key.clone()).collect()) } - fn environment(&self) -> String { - match &self.status { - Status::Connected { label, .. } => label.clone(), - other => other.to_string(), + /// One draggable row: the sidebar's `SidebarChrome` on the left, the thread breadcrumb right. + fn top_bar(&self, narrow: bool, cx: &mut Context) -> impl IntoElement { + let border = cx.theme().border; + let active = self + .shell() + .and_then(|shell| active_project(self.selected_project.as_deref(), shell)); + let open = self.sidebar_open && !narrow; + TitleBar::new() + .h(px(TOPBAR_HEIGHT)) + .pl_0() + .border_b_0() + .bg(gpui::transparent_black()) + .child(self.sidebar_chrome(open, SIDEBAR_WIDTH, cx)) + .child( + h_flex() + .flex_1() + .min_w_0() + .h_full() + .px_4() + .border_b_1() + .border_color(border) + .when(self.shell().is_some(), |row| { + row.child(self.breadcrumb(active, cx)) + }), + ) + } + + /// The web's `SidebarChrome`: toggle, then the brand and history while the sidebar is shown. + fn sidebar_chrome(&self, open: bool, width: f32, cx: &mut Context) -> impl IntoElement { + let backdrop = open && STAGE_BACKDROP; + let (brand, brand_muted) = if backdrop { + (gpui::white(), gpui::white().opacity(0.7)) + } else { + (cx.theme().foreground, cx.theme().muted_foreground) + }; + let toggle = Kbd::format(&Keystroke::parse("cmd-b").expect("valid keystroke")); + let shortcut = |keys| Kbd::format(&Keystroke::parse(keys).expect("valid keystroke")); + let border = cx.theme().border; + h_flex() + .h_full() + .flex_shrink_0() + .when(open, |row| row.w(px(width))) + .when(!open, |row| row.border_b_1().border_color(border)) + .pl(px(TITLEBAR_CONTENT_LEFT)) + .pr_3() + .gap_1() + .child( + nav_button( + "toggle-sidebar", + if open { + IconName::PanelLeftClose + } else { + IconName::PanelLeft + }, + format!("Toggle main sidebar ({toggle})").into(), + true, + backdrop, + cx, + ) + .on_click( + cx.listener(|this, _, window, cx| { + this.toggle_sidebar(narrow_window(window), cx) + }), + ), + ) + // Like the web, the brand and history live in the sidebar and hide with it. + .when(open, |bar| { + bar.child( + h_flex() + .ml_1() + .gap_1() + .text_sm() + .text_color(brand) + .child(div().font_weight(FontWeight::BOLD).child("T3")) + .child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(brand_muted) + .child("Code"), + ), + ) + .child( + nav_button( + "go-back", + IconName::ArrowLeft, + format!("Go back ({})", shortcut("cmd-[")).into(), + self.history.can_back(), + backdrop, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| this.go_back(cx))), + ) + .child( + nav_button( + "go-forward", + IconName::ArrowRight, + format!("Go forward ({})", shortcut("cmd-]")).into(), + self.history.can_forward(), + backdrop, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| this.go_forward(cx))), + ) + }) + } + + fn select_project(&mut self, id: String, cx: &mut Context) { + self.history.visit(id.clone()); + self.selected_project = Some(id); + self.picker_open = false; + self.sidebar_overlay_open = false; + self.request_branch(); + cx.notify(); + } + + fn go_back(&mut self, cx: &mut Context) { + if let Some(id) = self.history.back() { + self.sidebar_overlay_open = false; + self.selected_project = Some(id.to_owned()); + self.request_branch(); + cx.notify(); } } - fn sidebar(&self, cx: &mut Context) -> impl IntoElement { - let count = self - .shell() - .map_or(0, |shell| sidebar_items(&shell.rows, self.shelves).len()); + fn go_forward(&mut self, cx: &mut Context) { + if let Some(id) = self.history.forward() { + self.sidebar_overlay_open = false; + self.selected_project = Some(id.to_owned()); + self.request_branch(); + cx.notify(); + } + } + + fn toggle_sidebar(&mut self, narrow: bool, cx: &mut Context) { + (self.sidebar_open, self.sidebar_overlay_open) = + toggled(narrow, self.sidebar_open, self.sidebar_overlay_open); + cx.notify(); + } + + fn close_sidebar_overlay(&mut self, cx: &mut Context) { + self.sidebar_overlay_open = false; + cx.notify(); + } + + /// The web's mobile `Sheet`: a dimmed backdrop and a left drawer sliding in over the content. + fn sidebar_drawer(&self, width: f32, window: &Window, cx: &mut Context) -> AnyElement { + let drawer = div() + .absolute() + .top_0() + .bottom_0() + .left_0() + .w(px(width)) + .occlude() + .shadow_lg() + .child(self.sidebar_surface(width, cx)) + .child( + v_flex() + .size_full() + .child( + TitleBar::new() + .h(px(TOPBAR_HEIGHT)) + .pl_0() + .border_b_0() + .bg(gpui::transparent_black()) + .child(self.sidebar_chrome(true, width, cx)), + ) + .child( + div() + .flex_1() + .min_h_0() + .child(self.sidebar(width, window, cx)), + ), + ) + .with_animation( + "sidebar-drawer", + Animation::new(Duration::from_millis(SHEET_MS)).with_easing(ease_in_out), + |drawer, delta| drawer.left(px(-SHEET_SLIDE * (1. - delta))).opacity(delta), + ); + div() + .absolute() + .inset_0() + .child( + div() + .id("sidebar-backdrop") + .absolute() + .inset_0() + .cursor_default() + .bg(cx.theme().background.opacity(0.6)) + .on_click(cx.listener(|this, _, _, cx| this.close_sidebar_overlay(cx))) + .with_animation( + "sidebar-backdrop", + Animation::new(Duration::from_millis(SHEET_MS)).with_easing(ease_in_out), + |backdrop, delta| backdrop.opacity(delta), + ), + ) + .child(drawer) + .into_any_element() + } + + /// The sidebar's background, border and stage art, painted under the top bar and sidebar + /// so the art runs behind both, as the web's `SidebarStageBackdrop` does. + fn sidebar_surface(&self, width: f32, cx: &mut Context) -> impl IntoElement { + div() + .absolute() + .top_0() + .left_0() + .bottom_0() + .w(px(width)) + .bg(cx.theme().sidebar) + .border_r_1() + .border_color(cx.theme().sidebar_border) + .when(STAGE_BACKDROP, |surface| { + surface.child( + img(STAGE_DEV) + .absolute() + .top_0() + .left_0() + .w(px(width)) + .h(px(STAGE_BACKDROP_HEIGHT)) + .object_fit(ObjectFit::Cover), + ) + }) + } + + fn sidebar(&self, width: f32, window: &Window, cx: &mut Context) -> impl IntoElement { + self.sidebar_width.set(width); + let (top, shelves) = self.shell().map_or_else(Default::default, |shell| { + ( + top_rows(&shell.rows).len(), + shelves(&shell.rows, self.shelves), + ) + }); v_flex() - .w(px(SIDEBAR_WIDTH)) + .w(px(width)) .flex_shrink_0() .h_full() .gap_2() .p_2() - .bg(cx.theme().sidebar) - .border_r_1() - .border_color(cx.theme().sidebar_border) - .child(Input::new(&self.search).prefix(Icon::new(IconName::Search))) + .child(self.sidebar_header(window, cx)) .child( uniform_list( "threads", - count, + top, cx.processor(|this, range: std::ops::Range, _, cx| { let Some(shell) = this.shell() else { return Vec::new(); }; - let (muted, hover) = (cx.theme().muted_foreground, cx.theme().list_hover); - let items = sidebar_items(&shell.rows, this.shelves); - items[range] + let rows = top_rows(&shell.rows); + rows[range] .iter() - .map(|item| match *item { - SidebarItem::Row(index) => { - thread_row(&shell.rows[index], muted, hover).into_any_element() - } - SidebarItem::Header { - section, - count, - expanded, - } => { - let shelf = shelf_index(section); - shelf_header(section, count, expanded, muted, hover) - .on_click(cx.listener(move |this, _, _, cx| { - this.shelves[shelf] = !this.shelves[shelf]; - cx.notify(); - })) - .into_any_element() - } - }) + .map(|&index| this.thread_item(&shell.rows[index], cx)) .collect() }), ) .track_scroll(&self.list) .flex_1() - .min_h_0(), + .min_h(px(ROW_HEIGHT)), ) + .child( + v_flex().gap_px().min_h_0().children( + shelves + .into_iter() + .map(|shelf| self.shelf(shelf, cx)) + .collect::>(), + ), + ) + .child(self.sidebar_footer(cx)) + } + + /// A sidebar row; picking it marks the open thread, and closes the overlay like the web. + fn thread_item(&self, row: &ThreadRow, cx: &mut Context) -> gpui::Stateful { + let selected = self.selected_thread.as_deref() == Some(row.id.as_str()); + let id = row.id.clone(); + let project = self.shell().and_then(|shell| { + shell + .project_entries + .iter() + .find(|e| e.contains(&row.project_id)) + }); + let provider = self + .providers + .as_ref() + .and_then(|catalog| catalog.instance(&row.provider_instance)); + thread_row( + row, + project, + provider, + selected, + self.sidebar_width.get(), + cx, + ) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected_thread = Some(id.clone()); + this.sidebar_overlay_open = false; + cx.notify(); + })) + } + + /// One collapsible shelf: its header, then its rows while expanded. + fn shelf(&self, shelf: Shelf, cx: &mut Context) -> impl IntoElement + use<> { + let Shelf { + section, + count, + expanded, + } = shelf; + let index = shelf_index(section); + let (muted, hover) = (cx.theme().muted_foreground, cx.theme().list_hover); + let visible = self.shell().map_or(0, |shell| { + shelf_rows( + &shell.rows, + section, + expanded, + self.selected_thread.as_deref(), + ) + .len() + }); + v_flex() + .gap_px() + .min_h_0() + .child( + shelf_header(section, count, expanded, muted, hover).on_click(cx.listener( + move |this, _, _, cx| { + this.shelves[index] = !this.shelves[index]; + cx.notify(); + }, + )), + ) + .when(visible > 0, |shelf| { + shelf.child( + uniform_list( + ("shelf", index), + visible, + cx.processor(move |this, range: std::ops::Range, _, cx| { + let Some(shell) = this.shell() else { + return Vec::new(); + }; + let selected = this.selected_thread.as_deref(); + let rows = shelf_rows(&shell.rows, section, expanded, selected); + rows[range] + .iter() + .map(|&index| this.thread_item(&shell.rows[index], cx)) + .collect() + }), + ) + .h(px(visible.min(SHELF_VISIBLE_ROWS) as f32 * ROW_HEIGHT)) + .min_h_0(), + ) + }) + } + + /// The web's `SidebarChromeFooter`: utility buttons left, the update control right. + fn sidebar_footer(&self, cx: &App) -> impl IntoElement { + h_flex() + .pt_1() + .gap_px() + .child(footer_button( + "settings", + Icon::new(IconName::Settings), + "Settings", + false, + cx, + )) + .child(footer_button( + "pull-requests", + Icon::new(Icons::GitPullRequest), + "Pull requests", + false, + cx, + )) + .child(footer_button( + "usage", + Icon::new(Icons::ChartNoAxesColumn), + "Usage", + false, + cx, + )) + .child(div().flex_1()) + .child(footer_button( + "update", + Icon::new(Icons::CircleArrowUp), + "Update available", + true, + cx, + )) + } + + /// The web's `SidebarThreadHeader`: search, then the project filter, add project and new thread. + fn sidebar_header(&self, window: &Window, cx: &mut Context) -> impl IntoElement { + use gpui_kit_assets::IconName as Icons; + let has_projects = self + .shell() + .is_some_and(|shell| !shell.project_entries.is_empty()); + let new_thread = Kbd::format(&Keystroke::parse("cmd-n").expect("valid keystroke")); + let focused = self.search.focus_handle(cx).is_focused(window); + let (hover, ring) = (cx.theme().list_hover, cx.theme().ring.opacity(0.7)); + h_flex() + .gap_1() + .child( + // The web's unstyled search: only hover and focus paint the row. + h_flex() + .flex_1() + .min_w_0() + .h_8() + .px_2() + .gap_2() + .rounded(px(6.)) + .border_1() + .border_color(gpui::transparent_black()) + .text_color(cx.theme().muted_foreground) + .hover(move |style| style.bg(hover)) + .when(focused, |row| row.bg(hover).border_color(ring)) + .child(Icon::new(IconName::Search).size_4()) + .child( + div() + .flex_1() + .min_w_0() + .font_weight(FontWeight::MEDIUM) + .child(Input::new(&self.search).appearance(false).small()), + ), + ) + .when(has_projects, |row| { + row.child(header_icon_button( + "filter-projects", + Icons::ListFilter, + "Filter threads by project".into(), + cx, + )) + .child(header_icon_button( + "add-project", + Icons::FolderPlus, + "Add project".into(), + cx, + )) + }) + .child(header_icon_button( + "new-thread", + Icons::SquarePen, + format!("New thread ({new_thread})").into(), + cx, + )) } fn main_area(&self, cx: &mut Context) -> AnyElement { @@ -197,16 +720,6 @@ impl MainView { .flex_1() .min_w_0() .h_full() - .child( - h_flex() - .flex_shrink_0() - .h(px(40.)) - .px_4() - .items_center() - .border_b_1() - .border_color(cx.theme().border) - .child(self.breadcrumb(active, cx)), - ) .child( centered(cx) .gap_6() @@ -217,7 +730,13 @@ impl MainView { projects::with_remote_members(&shell.project_entries, &self.environments), cx, )) - .child(self.composer_box(cx)), + .child( + v_flex() + .w(px(720.)) + .max_w_full() + .child(self.composer_box(cx)) + .children(active.and_then(|entry| self.context_strip(entry, cx))), + ), ) .into_any_element() } @@ -262,7 +781,12 @@ impl MainView { .gap(px(6.)) .min_w_0() .text_color(muted) + .cursor_pointer() .hover(move |style| style.text_color(foreground)) + .tooltip(tooltip(format!( + "New thread in {}", + project_label(Some(entry)) + ))) .child(icon) .child( div() @@ -391,9 +915,7 @@ impl MainView { .children(environment_badge(index, entry, muted)) .on_click(move |_, _, cx| { view.update(cx, |this, cx| { - this.selected_project = Some(id.clone()); - this.picker_open = false; - cx.notify(); + this.select_project(id.clone(), cx); }); }) }); @@ -467,9 +989,7 @@ impl MainView { .on_click(move |_, _, cx| { let Some(id) = scratch_id.clone() else { return }; link_view.update(cx, |this, cx| { - this.selected_project = Some(id); - this.picker_open = false; - cx.notify(); + this.select_project(id, cx); }); }) .child("or start without a project") @@ -487,47 +1007,453 @@ impl MainView { ) } + /// The web's `ComposerSurface.ContextStrip` under the composer; none for the scratch project. + fn context_strip(&self, project: &ProjectEntry, cx: &App) -> Option { + let Status::Connected { label, machine, .. } = &self.status else { + return None; + }; + if project.scratch { + return None; + } + let defaults = self.providers.as_ref().map_or_else( + || Defaults::resolve(None, None, None), + |c| c.defaults(Some(&project.id)), + ); + let environment = strip::environment_label(label); + let branch = self + .branches + .get(&project.root) + .and_then(Option::as_ref) + .map(|branch| { + let label = branch.label(defaults.env_mode, defaults.start_from_origin); + // The branch is the one label that gives way in a narrow window. + strip_control("branch", Icons::GitBranch, label.clone(), label, cx) + .flex_shrink(1.) + .min_w_0() + }); + let is_repo = branch.is_some(); + let workspace = defaults.env_mode; + let runtime_mode = self.runtime_mode.unwrap_or(defaults.runtime_mode); + Some( + h_flex() + .w_full() + .items_center() + .gap_1() + .px_2() + .pt_2() + .child(strip_control( + "host", + machine_icon(*machine), + environment.clone(), + strip::tooltip_text(&environment, Some(HOST_SHORTCUT)), + cx, + )) + .children(is_repo.then(|| strip_separator(cx))) + .children(is_repo.then(|| { + strip_control( + "workspace", + workspace_icon(workspace), + workspace.label().to_owned(), + strip::tooltip_text(workspace.label(), Some(WORKSPACE_SHORTCUT)), + cx, + ) + })) + .child(div().flex_1()) + .children(branch) + .child(self.permissions_popover(runtime_mode, cx)), + ) + } + + /// The web's `ComposerContextPermissions` menu: opens above the chip, aligned to its end. + fn permissions_popover(&self, mode: RuntimeMode, cx: &App) -> impl IntoElement { + let view = self.permissions.read(cx).view.clone(); + let panel = self.permissions.clone(); + let content_panel = panel.clone(); + Popover::new("permissions") + .anchor(Anchor::BottomRight) + .open(self.permissions_open) + .p_0() + .on_open_change(move |open, _, cx| { + panel.update(cx, |panel, _| panel.preview = None); + view.update(cx, |this, cx| { + this.permissions_open = *open; + cx.notify(); + }) + .ok(); + }) + .trigger(PermissionsTrigger { mode, open: false }) + .content(move |_, _, cx| { + content_panel.update(cx, |panel, _| panel.selected = mode); + content_panel.clone() + }) + } + + fn open_permissions(&mut self, cx: &mut Context) { + let strip = self.shell().is_some_and(|shell| { + active_project(self.selected_project.as_deref(), shell).is_some_and(|e| !e.scratch) + }); + self.permissions_open = strip; + cx.notify(); + } + fn composer_box(&self, cx: &mut Context) -> impl IntoElement { + let trigger = self.providers.as_ref().and_then(|catalog| { + let project = self.selected_project.as_deref(); + catalog.trigger(project.filter(|id| !id.is_empty())) + }); v_flex() .w(px(720.)) .max_w_full() - .p_3() - .gap_2() - .rounded_xl() + .p_px() + .rounded(px(16.)) .border_1() - .border_color(cx.theme().border) + // The web's dark `--chat-composer-outline`. + .border_color(gpui::white().opacity(0.05)) .bg(cx.theme().secondary) .child( - Textarea::new(&self.composer) - .appearance(false) - .bordered(false), + // The web's `px-4 pt-4 pb-2` body; the negative margin cancels the textarea's own padding. + div().px_4().pt_4().pb_2().child( + Textarea::new(&self.composer) + .appearance(false) + .bordered(false) + .mx(px(-10.)) + .my(px(-8.)) + // The web's `leading-relaxed` at 14px. + .line_height(px(22.75)), + ), ) .child( h_flex() + .px_2() + .pb_2() .justify_between() + .child(header_icon_button( + "attach", + gpui_kit_assets::IconName::Paperclip, + "Attach files".into(), + cx, + )) .child( - div() - .id("attach-tooltip") - .tooltip(tooltip("Attach files")) + h_flex() + .gap_2() + .children(trigger.map(|trigger| model_trigger(&trigger, cx))) .child( - Button::new("attach") - .ghost() - .small() - .icon(gpui_kit_assets::IconName::Paperclip) - .on_click(|_, _, _| {}), + send_button( + !self.composer.read(cx).value().trim().is_empty(), + self.send_hovered, + cx, + ) + .on_hover(cx.listener( + |this, hovered: &bool, _, cx| { + this.send_hovered = *hovered; + cx.notify(); + }, + )), ), + ), + ) + } +} + +/// The web's `ComposerControl` at `xs`: a ghost button with a 12px icon and a 12px label. +fn strip_control( + id: &'static str, + icon: Icons, + label: String, + tip: String, + cx: &App, +) -> gpui::Stateful { + let theme = cx.theme(); + let (foreground, muted) = (theme.foreground, theme.muted_foreground); + div() + .id(id) + .flex() + .flex_shrink_0() + .items_center() + .gap(px(6.)) + .h(px(24.)) + .px(px(8.)) + .rounded(px(8.)) + .text_xs() + .text_color(muted) + .cursor_pointer() + .hover(move |style| style.bg(foreground.opacity(0.06)).text_color(foreground)) + .tooltip(tooltip(tip)) + .child(Icon::new(icon).size(px(12.))) + .child( + div() + .max_w(px(240.)) + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .child(label), + ) +} + +/// The permissions chip as a popover trigger. +#[derive(IntoElement)] +struct PermissionsTrigger { + mode: RuntimeMode, + open: bool, +} + +impl Selectable for PermissionsTrigger { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } + + fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + fn is_open(&self) -> bool { + self.open + } +} + +impl RenderOnce for PermissionsTrigger { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let tip = if self.open { + String::new() + } else { + strip::tooltip_text("Permissions", Some(PERMISSIONS_SHORTCUT)) + }; + let control = strip_control( + "permissions", + runtime_icon(self.mode), + self.mode.label().to_owned(), + tip, + cx, + ); + let foreground = cx.theme().foreground; + control.when(self.open, move |control| { + control.bg(foreground.opacity(0.06)).text_color(foreground) + }) + } +} + +/// The web's `ComposerPermissionsSection`: four icon segments, and the shown mode named below. +struct PermissionsPanel { + view: gpui::WeakEntity, + selected: RuntimeMode, + preview: Option, +} + +impl Render for PermissionsPanel { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let (foreground, muted) = (theme.foreground, theme.muted_foreground); + let (track, selected_bg, hover_bg) = ( + theme.input.opacity(0.4), + theme.input.opacity(0.72), + theme.accent, + ); + let (shown, is_selected) = strip::previewed(self.selected, self.preview); + let last = RuntimeMode::ALL.len() - 1; + let segments = RuntimeMode::ALL + .into_iter() + .enumerate() + .map(|(index, mode)| { + let chosen = mode == self.selected; + div() + .id(("runtime-mode", index)) + .flex() + .flex_1() + .h_6() + .items_center() + .justify_center() + .when(index == 0, |segment| segment.rounded_l_md()) + .when(index == last, |segment| segment.rounded_r_md()) + .cursor_pointer() + .when(chosen, |segment| { + segment.bg(selected_bg).text_color(foreground) + }) + .when(!chosen, |segment| { + segment + .text_color(muted) + .hover(move |style| style.bg(hover_bg).text_color(foreground)) + }) + .on_hover(cx.listener(move |this, hovered: &bool, _, cx| { + if *hovered { + this.preview = Some(mode); + } else if this.preview == Some(mode) { + this.preview = None; + } + cx.notify(); + })) + .on_click(cx.listener(move |this, _, _, cx| { + this.selected = mode; + this.preview = None; + this.view + .update(cx, |view, cx| { + view.runtime_mode = Some(mode); + view.permissions_open = false; + cx.notify(); + }) + .ok(); + cx.notify(); + })) + .child(Icon::new(runtime_icon(mode)).size(px(14.))) + }); + v_flex() + .w(px(288.)) + .gap(px(6.)) + .px_2() + .py(px(6.)) + .child( + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(muted) + .child("Permissions"), + ) + .child(h_flex().rounded_lg().bg(track).p(px(2.)).children(segments)) + .child( + v_flex() + .gap(px(2.)) + .child( + div() + .text_sm() + .text_color(if is_selected { foreground } else { muted }) + .child(shown.label()), ) .child( - Button::new("send") - .primary() - .rounded_full() - .icon(IconName::ArrowUp) - .on_click(|_, _, _| {}), + div() + .min_h_8() + .text_xs() + .line_height(px(16.)) + .text_color(muted) + .child(shown.description()), ), ) } } +/// The web's `Separator` between the environment and the workspace: `mx-0.5 h-3.5`. +fn strip_separator(cx: &App) -> gpui::Div { + div() + .flex_shrink_0() + .mx(px(2.)) + .w(px(1.)) + .h(px(14.)) + .bg(cx.theme().border) +} + +fn workspace_icon(mode: EnvMode) -> Icons { + match mode { + EnvMode::Local => Icons::Folder, + EnvMode::Worktree => Icons::FolderGit2, + } +} + +/// `runtimeModeConfig`'s icons. +fn runtime_icon(mode: RuntimeMode) -> Icons { + match mode { + RuntimeMode::ApprovalRequired => Icons::Lock, + RuntimeMode::AutoAcceptEdits => Icons::PenLine, + RuntimeMode::Auto => Icons::Sparkles, + RuntimeMode::FullAccess => Icons::ShieldOff, + } +} + +/// The web's `ProviderModelPicker` trigger, a `ComposerControl` at `sm`. Clicking does nothing yet. +fn model_trigger(trigger: &Trigger, cx: &App) -> gpui::Stateful { + let theme = cx.theme(); + let (foreground, muted) = (theme.foreground, theme.muted_foreground); + div() + .id("model-trigger") + .group("model-trigger") + .flex() + .flex_shrink_0() + .items_center() + .gap(px(6.)) + .h(px(28.)) + .px_2() + .rounded(px(8.)) + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground.opacity(0.8)) + .cursor_pointer() + .hover(move |style| style.bg(foreground.opacity(0.06)).text_color(foreground)) + .tooltip(tooltip(trigger.title.clone())) + .on_click(|_, _, _| {}) + .child(provider_mark(trigger, cx)) + .child( + div() + .max_w(px(224.)) + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .child(trigger.model.clone()), + ) + .children(trigger.effort.clone().map(|effort| { + div() + .ml(px(-2.)) + .max_w(px(96.)) + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_color(muted) + .group_hover("model-trigger", move |style| style.text_color(foreground)) + .child(effort) + })) +} + +/// The web's `ProviderInstanceIcon` at `size-4`: the driver's art, or its initials, with the +/// instance chip at the bottom-right. +fn provider_mark(trigger: &Trigger, cx: &App) -> gpui::Div { + let theme = cx.theme(); + let art = match provider_icon(&trigger.driver, theme.is_dark()) { + Some(path) => img(path).size_full().into_any_element(), + None => div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_size(px(10.)) + .font_weight(FontWeight::SEMIBOLD) + .child(crate::providers::initials(&trigger.display_name)) + .into_any_element(), + }; + div() + .relative() + .flex_shrink_0() + .size_4() + .child(art) + .children(trigger.badge.as_ref().map(|badge| { + let (bg, fg) = match badge.accent { + Some((bg, fg)) => (rgb(bg).into(), rgb(fg).into()), + None => (theme.background, theme.muted_foreground), + }; + div() + .absolute() + .right(px(-2.)) + .bottom(px(-2.)) + .h(px(12.)) + .min_w(px(12.)) + .px(px(2.)) + .flex() + .items_center() + .justify_center() + .rounded_full() + .border_1() + .border_color(theme.secondary) + .shadow_sm() + .bg(bg) + .text_color(fg) + .text_size(px(7.)) + .line_height(px(7.)) + .font_weight(FontWeight::SEMIBOLD) + .child(badge.initials.clone()) + })) +} + /// The project name as plain heading text that underlines on hover and while its picker is open. /// Under a scratch draft it is the `InlineButton` `picker` tone at `text-sm`, `font-medium`. #[derive(IntoElement)] @@ -624,6 +1550,181 @@ fn tooltip( } } +/// The web's `SidebarHeaderIconButton`: a 28px ghost button whose icon turns to the foreground on hover. +fn header_icon_button( + id: &'static str, + icon: gpui_kit_assets::IconName, + label: gpui::SharedString, + cx: &App, +) -> impl IntoElement + use<> { + ghost_icon_button(id, Icon::new(icon).size_4(), label, true, cx) +} + +/// The top bar's `icon-sm` buttons: `ghost`, or `media-navigation` on the stage art. +/// Disabled ones stay dim and ignore the pointer. +fn nav_button( + id: &'static str, + icon: IconName, + label: gpui::SharedString, + enabled: bool, + on_backdrop: bool, + cx: &App, +) -> gpui::Stateful { + let icon = Icon::new(icon).size(px(14.)); + if !on_backdrop { + return ghost_icon_button(id, icon, label, enabled, cx); + } + let white = gpui::white(); + icon_button( + id, + icon, + label, + enabled, + white.opacity(0.9), + white.opacity(0.1), + white, + ) +} + +fn ghost_icon_button( + id: &'static str, + icon: Icon, + label: gpui::SharedString, + enabled: bool, + cx: &App, +) -> gpui::Stateful { + let theme = cx.theme(); + // Translucent like the web's `--accent`, so it shows on the composer as well as the sidebar. + let (idle, hover) = (theme.muted_foreground, theme.foreground.opacity(0.06)); + icon_button(id, icon, label, enabled, idle, hover, theme.foreground) +} + +fn icon_button( + id: &'static str, + icon: Icon, + label: gpui::SharedString, + enabled: bool, + idle: Hsla, + hover_bg: Hsla, + hover_fg: Hsla, +) -> gpui::Stateful { + div() + .id(id) + .flex_shrink_0() + .size(px(28.)) + .flex() + .items_center() + .justify_center() + .rounded(px(6.)) + .text_color(idle) + .when(enabled, |button| { + button + .cursor_pointer() + .hover(move |style| style.bg(hover_bg).text_color(hover_fg)) + .tooltip(tooltip(label)) + }) + // The web's `disabled:opacity-64`. + .when(!enabled, |button| button.opacity(0.64)) + .child(icon) +} + +/// The sidebar footer's `size-8` menu button, `rounded-full` for the update control. +fn footer_button( + id: &'static str, + icon: Icon, + label: &'static str, + round: bool, + cx: &App, +) -> gpui::Stateful { + let theme = cx.theme(); + let (idle, hover_bg, hover_fg) = (theme.muted_foreground, theme.list_hover, theme.foreground); + div() + .id(id) + .flex_shrink_0() + .size_8() + .flex() + .items_center() + .justify_center() + .when(round, |button| button.rounded_full()) + .when(!round, |button| button.rounded(px(6.))) + .text_color(idle) + .cursor_pointer() + .hover(move |style| style.bg(hover_bg).text_color(hover_fg)) + .tooltip(tooltip(label)) + .child(icon.size_4()) +} + +/// The web's `ComposerPrimaryActions` send button: stage art in dev builds, else `message-action`. +fn send_button(enabled: bool, hovered: bool, cx: &App) -> gpui::Stateful { + let theme = cx.theme(); + let (bg, hover_bg, fg) = if STAGE_BACKDROP { + ( + gpui::transparent_black(), + gpui::white().opacity(0.1), + gpui::white(), + ) + } else if enabled { + (theme.primary, theme.primary_hover, theme.primary_foreground) + } else { + ( + theme.foreground.opacity(0.12), + theme.foreground.opacity(0.12), + theme.muted_foreground, + ) + }; + // The web's `hover:scale-105` over 150ms: the circle grows 5% inside a fixed slot. + let grown = enabled && hovered; + div() + .id("send") + .group("send") + .relative() + .flex_shrink_0() + .size(px(32.)) + .when(enabled, |slot| { + slot.cursor_pointer().tooltip(tooltip("Submit message")) + }) + .child( + div() + .absolute() + .inset_0() + .rounded_full() + .bg(bg) + .when(STAGE_BACKDROP && !enabled, |circle| circle.opacity(0.64)) + .when(STAGE_BACKDROP, |circle| { + circle.child( + img(STAGE_DEV_BUTTON) + .absolute() + .size_full() + // `overflow_hidden` does not round-clip images. + .rounded_full() + .object_fit(ObjectFit::Cover), + ) + }) + .child( + div() + .absolute() + .size_full() + .rounded_full() + .flex() + .items_center() + .justify_center() + .text_color(fg) + .when(enabled, |face| { + face.group_hover("send", move |style| style.bg(hover_bg)) + }) + .child(Icon::new(IconName::ArrowUp).size_4()), + ) + .with_animation( + ("send-grow", usize::from(grown)), + Animation::new(Duration::from_millis(150)).with_easing(ease_in_out), + move |circle, delta| { + let inset = px(-0.8) * if grown { delta } else { 1. - delta }; + circle.top(inset).left(inset).right(inset).bottom(inset) + }, + ), + ) +} + fn machine_icon(kind: MachineKind) -> gpui_kit_assets::IconName { use gpui_kit_assets::IconName as Icons; match kind { @@ -747,27 +1848,18 @@ fn centered(cx: &Context) -> gpui::Div { .text_color(cx.theme().foreground) } -fn status_tag(status: ThreadStatus) -> Option { - let (tag, label) = match status { - ThreadStatus::Running | ThreadStatus::Starting => (Tag::info(), "Running"), - ThreadStatus::Waiting => (Tag::warning(), "Waiting"), - ThreadStatus::Queued | ThreadStatus::Preparing => (Tag::secondary(), "Queued"), - ThreadStatus::Failed => (Tag::danger(), "Failed"), - _ => return None, - }; - Some(tag.small().child(label)) -} - #[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum SidebarItem { - Row(usize), - Header { - section: Section, - count: usize, - expanded: bool, - }, +struct Shelf { + section: Section, + count: usize, + expanded: bool, } +/// Rows a shelf shows before it scrolls. +const SHELF_VISIBLE_ROWS: usize = 6; +/// The web's shelf header and footer button size (`h-8`, `size-8`). +const SHELF_HEADER_HEIGHT: f32 = 32.; + fn shelf_index(section: Section) -> usize { match section { Section::Working => 0, @@ -776,40 +1868,48 @@ fn shelf_index(section: Section) -> usize { } } -/// The web's default list: pinned and active rows, then a header per shelf (Working and -/// Snoozed only when non-empty, Settled always) with its rows only while expanded. -fn sidebar_items(rows: &[ThreadRow], expanded: [bool; 3]) -> Vec { +/// The pinned and active rows, listed above the shelves. +fn top_rows(rows: &[ThreadRow]) -> Vec { + let listed = |row: &ThreadRow| matches!(row.section, Section::Pinned | Section::Active); + (0..rows.len()) + .filter(|&index| listed(&rows[index])) + .collect() +} + +/// A shelf's rows: all while expanded, else only the open thread (`routeThreadOnly`). +fn shelf_rows( + rows: &[ThreadRow], + section: Section, + expanded: bool, + selected: Option<&str>, +) -> Vec { + (0..rows.len()) + .filter(|&index| { + let row = &rows[index]; + row.section == section && (expanded || selected == Some(row.id.as_str())) + }) + .collect() +} + +/// The web's shelves, bottom-anchored: Working and Snoozed only when non-empty, Settled always. +fn shelves(rows: &[ThreadRow], expanded: [bool; 3]) -> Vec { if rows.is_empty() { return Vec::new(); } - let mut items = Vec::with_capacity(rows.len() + 3); - for (index, row) in rows.iter().enumerate() { - if matches!(row.section, Section::Pinned | Section::Active) { - items.push(SidebarItem::Row(index)); - } - } - for section in [Section::Working, Section::Snoozed, Section::Settled] { - let count = rows.iter().filter(|row| row.section == section).count(); - if count == 0 && section != Section::Settled { - continue; - } - let expanded = expanded[shelf_index(section)]; - items.push(SidebarItem::Header { - section, - count, - expanded, - }); - if expanded { - let shelf = rows - .iter() - .enumerate() - .filter(|(_, row)| row.section == section); - items.extend(shelf.map(|(index, _)| SidebarItem::Row(index))); - } - } - items + [Section::Working, Section::Snoozed, Section::Settled] + .into_iter() + .filter_map(|section| { + let count = rows.iter().filter(|row| row.section == section).count(); + (count > 0 || section == Section::Settled).then(|| Shelf { + section, + count, + expanded: expanded[shelf_index(section)], + }) + }) + .collect() } +/// The web's `CollapsibleSectionHeader`: label left, then the count and a chevron right. fn shelf_header( section: Section, count: usize, @@ -829,83 +1929,352 @@ fn shelf_header( }; h_flex() .id(label) - .h(px(ROW_HEIGHT)) - .w_full() + .h(px(SHELF_HEADER_HEIGHT)) + .mx(px(2.)) .px_2() .gap_2() - .items_end() - .pb_2() .rounded_md() .text_xs() + .font_weight(FontWeight::MEDIUM) .text_color(muted) + .cursor_pointer() .hover(|style| style.bg(hover)) - .child(Icon::new(chevron).small()) .child(div().flex_1().child(label)) - .child(count.to_string()) + .child( + div() + .font_weight(FontWeight::NORMAL) + .child(count.to_string()), + ) + .child(Icon::new(chevron).size(px(12.))) } -fn thread_row(row: &ThreadRow, muted: Hsla, hover: Hsla) -> impl IntoElement + use<> { - v_flex() - .id(row.id.clone()) - .h(px(ROW_HEIGHT)) - .w_full() - .overflow_hidden() - .px_2() - .py_1() +/// The web's `text-info-foreground`, `text-error-foreground` and `text-warning-foreground`. +fn top_status_color(status: TopStatus, dark: bool) -> Option { + match (status, dark) { + (TopStatus::Waiting, _) => None, + (TopStatus::Working, true) => Some(0x60a5fa), + (TopStatus::Working, false) => Some(0x1d4ed8), + (TopStatus::Limited, true) => Some(0xfbbf24), + (TopStatus::Limited, false) => Some(0xb45309), + (TopStatus::Failed, true) => Some(0xf87171), + (TopStatus::Failed, false) => Some(0xb91c1c), + } +} + +fn top_status_icon(status: TopStatus) -> Icons { + match status { + TopStatus::Working => Icons::CircleDashed, + TopStatus::Waiting => Icons::CircleDotDashed, + TopStatus::Limited | TopStatus::Failed => Icons::CircleAlert, + } +} + +/// The project avatar at the favicon's `size-4`: the monogram on its tinted square. +fn project_badge(entry: Option<&ProjectEntry>, dark: bool, mono: gpui::SharedString) -> gpui::Div { + let badge = div().flex_shrink_0().size_4(); + let Some(entry) = entry else { + return badge; + }; + let color: Hsla = rgb(entry.color_hex(dark)).into(); + if entry.scratch { + return badge.child( + Icon::empty() + .path(MESSAGE_SQUARE_DASHED) + .size_4() + .text_color(color), + ); + } + badge + .flex() + .items_center() .justify_center() + .rounded(px(4.)) + .bg(color.opacity(0.14)) + .text_color(color) + .text_size(px(8.25)) + .font_weight(FontWeight::BOLD) + .font_family(mono) + .child(entry.initials.clone()) +} + +/// The web's `PULL_REQUEST_STATE_PRESENTATION` tones. +fn pr_color(state: PrState, dark: bool) -> Option { + let (hex, alpha) = match (state, dark) { + (PrState::Pending, _) => return None, + (PrState::Open, false) => (0x059669, 1.), + (PrState::Open, true) => (0x6ee7b7, 0.9), + (PrState::Draft, false) => (0x71717a, 1.), + (PrState::Draft, true) => (0xa1a1aa, 0.8), + (PrState::Closed, false) => (0xdc2626, 1.), + (PrState::Closed, true) => (0xfca5a5, 0.9), + (PrState::Merged, false) => (0x7c3aed, 1.), + (PrState::Merged, true) => (0xc4b5fd, 0.9), + }; + Some(Hsla::from(rgb(hex)).opacity(alpha)) +} + +fn pr_icon(badge: &PrBadge) -> Icons { + match (badge.stack, badge.state) { + (true, _) => Icons::Layers, + (false, PrState::Draft) => Icons::GitPullRequestDraft, + (false, PrState::Closed) => Icons::GitPullRequestClosed, + (false, PrState::Merged) => Icons::GitMerge, + (false, _) => Icons::GitPullRequestArrow, + } +} + +/// The web's `MiddleTruncate` with a character budget in place of measured text. +fn middle_truncate(text: &str, budget: usize) -> String { + let chars: Vec = text.chars().collect(); + if chars.len() <= budget || budget < 3 { + return text.to_owned(); + } + let keep = budget - 1; + let (head, tail) = (keep.div_ceil(2), keep / 2); + let start: String = chars[..head].iter().collect(); + let end: String = chars[chars.len() - tail..].iter().collect(); + format!("{start}\u{2026}{end}") +} + +/// How many `text-xs` characters fit the branch: the card's width less its padding, icons and +/// the pull request badge. +fn branch_budget( + sidebar_width: f32, + worktree: bool, + pr: Option<&PrBadge>, + provider: bool, +) -> usize { + const CHAR_WIDTH: f32 = 6.4; + const GAP: f32 = 6.; + // Sidebar padding and the card's horizontal padding. + let mut room = sidebar_width - 16. - 20.; + if worktree { + room -= 14. + GAP; + } + if provider { + room -= 16. + GAP; + } + if let Some(pr) = pr { + room -= 12. + 4. + pr.text.chars().count() as f32 * CHAR_WIDTH + GAP; + } + (room / CHAR_WIDTH).max(0.) as usize +} + +fn card_action( + id: &'static str, + label: &'static str, + muted: Hsla, + foreground: Hsla, +) -> gpui::Stateful { + h_flex() + .id(id) + .h_full() + .gap_1() + .px(px(6.)) .rounded_md() - .hover(|style| style.bg(hover)) + .text_xs() + .text_color(muted) + .cursor_pointer() + .hover(move |style| style.text_color(foreground)) + .tooltip(tooltip(label)) +} + +/// The group's label, else the project's own title. +fn project_name<'a>(row: &'a ThreadRow, entry: Option<&'a ProjectEntry>) -> &'a str { + entry.map_or(row.project.as_str(), |entry| entry.title.as_str()) +} + +/// The web's card `SidebarThreadRow`. Snooze and Settle appear on hover, as in the web, but do +/// nothing yet. +fn thread_row( + row: &ThreadRow, + project: Option<&ProjectEntry>, + provider: Option<(&str, String)>, + selected: bool, + sidebar_width: f32, + cx: &App, +) -> gpui::Stateful { + let theme = cx.theme(); + let dark = theme.is_dark(); + let (muted, foreground) = (theme.muted_foreground, theme.foreground); + let group = gpui::SharedString::from(format!("thread-{}", row.id)); + let name = project_name(row, project); + let status = row.top.map(|top| { + let color = top_status_color(top, dark).map_or(muted, |hex| rgb(hex).into()); + h_flex() + .gap_1() + .font_weight(FontWeight::MEDIUM) + .text_color(color) + .group_hover(group.clone(), |style| style.invisible()) + .child(Icon::new(top_status_icon(top)).size_4()) + .child(top.label()) + }); + let actions = h_flex() + .absolute() + .top_0() + .right_0() + .h_full() + .invisible() + .group_hover(group.clone(), |style| style.visible()) .child( - div() - .text_xs() - .text_color(muted) - .truncate() - .child(row.project.clone()), + card_action("snooze", "Snooze thread", muted, foreground) + .child(Icon::new(Icons::Clock).size(px(12.))), ) .child( - h_flex() - .gap_2() + card_action("settle", "Settle thread", muted, foreground) + .child(Icon::new(Icons::Check).size(px(14.))) + .child("Settle"), + ); + let provider = provider.map(|(driver, display_name)| { + let art = match provider_icon(driver, dark) { + Some(path) => img(path).size_full().into_any_element(), + None => div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_size(px(10.)) + .font_weight(FontWeight::SEMIBOLD) + .child(crate::providers::initials(&display_name)) + .into_any_element(), + }; + div().flex_shrink_0().size_4().opacity(0.6).child(art) + }); + div() + .id(row.id.clone()) + .group(group) + .h(px(ROW_HEIGHT)) + .w_full() + .py(px(2.)) + .cursor_pointer() + .child( + v_flex() + .h(px(CARD_HEIGHT)) + .w_full() + .overflow_hidden() + .px(px(10.)) + .py_2() + .rounded_md() + .when(selected, |card| card.bg(theme.list_active)) + .when(!selected, |card| { + card.hover(|style| style.bg(theme.list_hover)) + }) + .child( + h_flex() + .h(px(20.)) + .flex_shrink_0() + .items_center() + .gap(px(6.)) + .child(project_badge(project, dark, theme.mono_font_family.clone())) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_xs() + .text_color(muted) + .child(name.to_owned()), + ) + .child( + div() + .relative() + .flex() + .flex_shrink_0() + .items_center() + .justify_end() + .h_full() + .min_w_8() + .text_xs() + .children(status) + .child(actions), + ), + ) .child( div() - .flex_1() - .min_w_0() - .text_sm() + .mt_1() + .h(px(20.)) + .flex_shrink_0() .truncate() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) .child(row.title.clone()), ) - .children(status_tag(row.status).map(|tag| div().flex_shrink_0().child(tag))), + .child( + h_flex() + .mt(px(2.)) + .h(px(16.)) + .flex_shrink_0() + .items_center() + .gap(px(6.)) + .text_xs() + .text_color(muted) + .when(row.worktree, |line| { + line.child(Icon::new(Icons::FolderGit2).size(px(14.))) + }) + .child(div().flex_1().min_w_0().truncate().children( + row.branch.as_deref().map(|branch| { + middle_truncate( + branch, + branch_budget( + sidebar_width, + row.worktree, + row.pr.as_ref(), + provider.is_some(), + ), + ) + }), + )) + .children(row.pr.as_ref().map(|badge| { + let tone = pr_color(badge.state, dark).unwrap_or(muted); + h_flex() + .flex_shrink_0() + .gap_1() + .child(Icon::new(pr_icon(badge)).size(px(12.)).text_color(tone)) + .child(div().text_color(muted).child(badge.text.clone())) + })) + .children(provider), + ), ) - .children(row.branch.as_ref().map(|branch| { - div() - .text_xs() - .text_color(muted) - .truncate() - .child(branch.clone()) - })) } impl Render for MainView { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let width = f32::from(window.viewport_size().width); + let narrow = is_narrow(width); + let docked = self.sidebar_open && !narrow; + let overlay = narrow && self.sidebar_overlay_open; v_flex() + .relative() .size_full() .bg(cx.theme().background) .text_color(cx.theme().foreground) - .child( - TitleBar::new().child("T3 Code").child( - div() - .ml_2() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(self.environment()), - ), + .when(overlay, |root| root.key_context(OVERLAY_CONTEXT)) + .on_action(cx.listener(|this, _: &ToggleSidebar, window, cx| { + this.toggle_sidebar(narrow_window(window), cx) + })) + .on_action( + cx.listener(|this, _: &DismissSidebar, _, cx| this.close_sidebar_overlay(cx)), ) + .on_action(cx.listener(|this, _: &OpenPermissions, _, cx| this.open_permissions(cx))) + .on_action(cx.listener(|this, _: &GoBack, _, cx| this.go_back(cx))) + .on_action(cx.listener(|this, _: &GoForward, _, cx| this.go_forward(cx))) + .when(docked, |root| { + root.child(self.sidebar_surface(SIDEBAR_WIDTH, cx)) + }) + .child(self.top_bar(narrow, cx)) .child( h_flex() .flex_1() .min_h_0() - .child(self.sidebar(cx)) + .when(docked, |row| { + row.child(self.sidebar(SIDEBAR_WIDTH, window, cx)) + }) .child(self.main_area(cx)), ) + .when(overlay, |root| { + root.child(self.sidebar_drawer(overlay_width(width), window, cx)) + }) } } @@ -913,6 +2282,22 @@ impl Render for MainView { mod tests { use super::*; + #[test] + fn the_sidebar_overlays_at_the_webs_breakpoint() { + assert!(is_narrow(767.)); + assert!(!is_narrow(768.)); + assert_eq!(overlay_width(583.), 448.); + assert_eq!(overlay_width(400.), 352.); + } + + #[test] + fn toggling_flips_only_the_state_for_the_viewport() { + assert_eq!(toggled(true, true, false), (true, true)); + assert_eq!(toggled(true, false, true), (false, false)); + assert_eq!(toggled(false, true, false), (false, false)); + assert_eq!(toggled(false, true, true), (false, true)); + } + fn entry(id: &str, scratch: bool) -> ProjectEntry { ProjectEntry { key: id.to_owned(), @@ -923,6 +2308,7 @@ mod tests { color: 0, pinned: false, scratch, + root: String::new(), logical_key: None, remote: Vec::new(), } @@ -935,13 +2321,42 @@ mod tests { project: project_id.to_owned(), title: String::new(), branch: None, - status: ThreadStatus::Idle, + worktree: false, + provider_instance: String::new(), + top: None, + pr: None, section: Section::Active, } } #[test] - fn sidebar_items_keep_shelves_collapsed_until_expanded() { + fn the_card_names_its_project_from_the_group_or_the_row() { + let mut thread = row("t"); + thread.project = "own".to_owned(); + assert_eq!(project_name(&thread, Some(&entry("group", false))), "group"); + assert_eq!(project_name(&thread, None), "own"); + } + + #[test] + fn long_branches_lose_their_middle() { + assert_eq!(middle_truncate("main", 10), "main"); + assert_eq!( + middle_truncate("yordis/t3/token-security-comprison", 12), + "yordis\u{2026}rison" + ); + assert_eq!(middle_truncate("abcdefgh", 2), "abcdefgh"); + let badge = PrBadge { + state: PrState::Open, + stack: false, + text: "19".to_owned(), + }; + let plain = branch_budget(300., true, None, true); + assert!(branch_budget(300., true, Some(&badge), true) < plain); + assert!(branch_budget(448., true, None, true) > plain); + } + + #[test] + fn shelves_list_only_what_the_web_does() { let in_section = |id: &str, section| ThreadRow { section, ..row(id) }; let rows = vec![ in_section("p", Section::Pinned), @@ -949,32 +2364,28 @@ mod tests { in_section("w", Section::Working), in_section("s", Section::Settled), ]; - let header = |section, count, expanded| SidebarItem::Header { + let shelf = |section, expanded| Shelf { section, - count, + count: 1, expanded, }; + assert_eq!(top_rows(&rows), [0, 1]); assert_eq!( - sidebar_items(&rows, [false; 3]), + shelves(&rows, [false; 3]), [ - SidebarItem::Row(0), - SidebarItem::Row(1), - header(Section::Working, 1, false), - header(Section::Settled, 1, false), + shelf(Section::Working, false), + shelf(Section::Settled, false) ] ); assert_eq!( - sidebar_items(&rows, [true, false, true]), - [ - SidebarItem::Row(0), - SidebarItem::Row(1), - header(Section::Working, 1, true), - SidebarItem::Row(2), - header(Section::Settled, 1, true), - SidebarItem::Row(3), - ] + shelves(&rows, [true, false, true]), + [shelf(Section::Working, true), shelf(Section::Settled, true)] ); - assert!(sidebar_items(&[], [true; 3]).is_empty()); + assert_eq!(shelf_rows(&rows, Section::Settled, true, None), [3]); + assert!(shelf_rows(&rows, Section::Settled, false, None).is_empty()); + assert!(shelf_rows(&rows, Section::Settled, false, Some("w")).is_empty()); + assert_eq!(shelf_rows(&rows, Section::Settled, false, Some("s")), [3]); + assert!(shelves(&[], [true; 3]).is_empty()); } #[test] From f466ab6d9c43a834cf629d6563578e3bb352931e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 09:29:49 -0500 Subject: [PATCH 15/82] feat(desktop-gpui): open a thread's transcript from the sidebar Clicking a thread card did nothing but highlight it. It now opens the thread in the main area as a read-only transcript. The window subscribes to orchestration.subscribeThread for the selected thread, interrupting the previous stream when the selection changes and resubscribing after a reconnect. A small reducer turns the snapshot and turn-item updates into user and assistant messages plus one muted line per tool item, drawn in a variable-height list that follows the tail. The breadcrumb shows the thread title, and back/forward now walk project and thread routes. --- apps/desktop-gpui/README.md | 7 +- apps/desktop-gpui/src/connection.rs | 213 +++++++++++++++- apps/desktop-gpui/src/history.rs | 54 ++-- apps/desktop-gpui/src/main.rs | 1 + apps/desktop-gpui/src/rpc.rs | 24 ++ apps/desktop-gpui/src/status.rs | 7 + apps/desktop-gpui/src/thread.rs | 312 ++++++++++++++++++++++++ apps/desktop-gpui/src/ui.rs | 259 +++++++++++++++++--- apps/desktop-gpui/src/wire/generated.rs | 149 +++++++++++ apps/desktop-gpui/src/wire/mod.rs | 3 +- apps/desktop-gpui/tests/wire_types.rs | 6 +- 11 files changed, 965 insertions(+), 70 deletions(-) create mode 100644 apps/desktop-gpui/src/thread.rs diff --git a/apps/desktop-gpui/README.md b/apps/desktop-gpui/README.md index f0da0ef8ad1f..e9e8f2fe2069 100644 --- a/apps/desktop-gpui/README.md +++ b/apps/desktop-gpui/README.md @@ -2,7 +2,7 @@ Native desktop client written in Rust with [GPUI](https://github.com/zed-industries/zed/tree/main/crates/gpui). It is meant to replace `apps/desktop` (Electron) over time and never embeds Node or Chromium. It starts the T3 server as a child process, authenticates without pairing, and opens the RPC WebSocket. -Status: connection shell. The window shows the connection state ("Starting server...", "Connecting...", "Connected to · N projects · M threads", or the error). The counts come from a live `orchestration.subscribeShell` subscription. Once connected, it draws a static skeleton of the main screen with [gpui-component](https://github.com/longbridge/gpui-component): a thread list laid out like the web sidebar (pinned and active threads, then collapsible Working, Snoozed and Settled shelves) and an empty composer. Only the shelf headers are interactive. +Status: connection shell. The window shows the connection state ("Starting server...", "Connecting...", "Connected to · N projects · M threads", or the error). The counts come from a live `orchestration.subscribeShell` subscription. Once connected, it draws a static skeleton of the main screen with [gpui-component](https://github.com/longbridge/gpui-component): a thread list laid out like the web sidebar (pinned and active threads, then collapsible Working, Snoozed and Settled shelves) and an empty composer. Clicking a thread opens its read-only transcript (user and assistant messages, tool work as muted lines) from a live `orchestration.subscribeThread` subscription; the composer is still static. ## Run @@ -52,7 +52,8 @@ vp run --filter @t3tools/desktop-gpui test # cargo test 2. Polls `GET /.well-known/t3/environment` until the server is ready. 3. Exchanges the HMAC bootstrap token for a bearer session, mints a WebSocket ticket, and opens `/ws`. 4. Subscribes to `orchestration.subscribeShell`, acks each stream chunk, and reduces snapshot and delta items into project and thread counts (`src/rpc.rs`, `src/shell.rs`). -5. Sends an RPC `Ping` every 5 seconds and reconnects with backoff after 15 seconds of silence or when the stream ends. +5. Subscribes to `orchestration.subscribeThread` for the selected thread and reduces its snapshot and `turn-item.updated` events into transcript rows (`src/thread.rs`). Switching threads interrupts the previous stream; a reconnect resubscribes from a fresh snapshot. +6. Sends an RPC `Ping` every 5 seconds and reconnects with backoff after 15 seconds of silence or when the stream ends. On quit the app sends SIGTERM to the server it spawned and SIGKILL after 5 seconds. If the app is killed, the server notices its parent changed within about 2 seconds and shuts itself down. @@ -68,7 +69,7 @@ T3_UPDATE_GENERATED=1 cargo test --test wire_types Without the variable the same test fails when the committed file is stale. Drift in the schema itself is caught by `generate --check` in CI and `vp run check:changed`. -Known gaps: typify cannot flatten the `projectIcon` union (an `anyOf` with a `{kind: string}` catch-all), so `ProjectsEntry.projectIcon` is kept opaque (`serde_json::Value`) through the `OPAQUE` list. Unions without a discriminator generate as `#[serde(untagged)]` enums. +Known gaps: the success type of `orchestration.subscribeThread` is empty in the schema, so `src/thread.rs` reads its items from JSON. typify cannot flatten the `projectIcon` union (an `anyOf` with a `{kind: string}` catch-all), so `ProjectsEntry.projectIcon` is kept opaque (`serde_json::Value`) through the `OPAQUE` list. Unions without a discriminator generate as `#[serde(untagged)]` enums. `server.getConfig` is not generated: typify cannot represent its schema. `src/providers.rs` decodes the small subset the composer's model trigger reads (provider snapshots and the provider and default-model settings) straight from the JSON, dropping entries it cannot read. diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 758939d0bbde..67faeb9bb90c 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -5,14 +5,19 @@ use crate::electron_dpop; use crate::projects::MachineKind; use crate::providers::Catalog; use crate::rpc::{ - Event, PING_FRAME, ProjectsMutate, ServerGetConfig, Session, SubscribeShell, VcsListRefs, + Event, PING_FRAME, ProjectsMutate, ServerGetConfig, Session, SubscribeShell, SubscribeThread, + VcsListRefs, }; use crate::shell::ShellState; use crate::status::{Status, StatusSender, publish}; use crate::strip::{self, Branch}; -use crate::wire::{ProjectMutation, ProjectMutationType, ShellItem, SubscribeShellPayload}; +use crate::thread::ThreadState; +use crate::wire::{ + ProjectMutation, ProjectMutationType, ShellItem, SubscribeShellPayload, SubscribeThreadPayload, +}; +use futures_util::stream::SplitSink; use futures_util::{SinkExt, StreamExt}; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::time::Duration; use tokio::time::{Instant, MissedTickBehavior, interval, sleep, sleep_until}; use tokio_tungstenite::tungstenite::Message; @@ -44,6 +49,8 @@ pub enum Action { LoadBranch { root: String, }, + /// The thread whose transcript the window shows; `None` closes the subscription. + SelectThread(Option), } pub type ActionSender = async_channel::Sender; @@ -55,6 +62,7 @@ impl Action { Self::SetProjectPinned { pinned: true, .. } => "pin project", Self::SetProjectPinned { pinned: false, .. } => "unpin project", Self::LoadBranch { .. } => "load the branch", + Self::SelectThread(_) => "open the thread", } } } @@ -159,6 +167,8 @@ pub async fn run( ) { let client = auth::http_client(); let mut bearer: Option = None; + // Outlives a socket: a reconnect subscribes to the thread again. + let mut selected: Option = None; let mut backoff = Backoff::new(); loop { publish(status, Status::Connecting); @@ -174,7 +184,7 @@ pub async fn run( }, ); backoff.reset(); - serve(socket, environment, actions, status).await + serve(socket, environment, actions, &mut selected, status).await } Err(message) => message, }; @@ -236,6 +246,7 @@ async fn serve( socket: Socket, environment: &Environment, actions: &ActionReceiver, + selected: &mut Option, status: &StatusSender, ) -> String { let (mut sink, mut stream) = socket.split(); @@ -258,8 +269,19 @@ async fn serve( None } }; - // Whatever was clicked while disconnected is stale by now. - while actions.try_recv().is_ok() {} + // Whatever was clicked while disconnected is stale by now, except which thread is open. + while let Ok(action) = actions.try_recv() { + if let Action::SelectThread(id) = action { + *selected = id; + } + } + let mut threads = Threads::default(); + if let Err(error) = threads + .select(selected.as_deref(), &mut session, &mut sink) + .await + { + return error; + } let mut pending: HashMap = HashMap::new(); // Branch lookups by request id, with the workspace root each was for. let mut branches: HashMap = HashMap::new(); @@ -278,6 +300,15 @@ async fn serve( return "window closed".to_owned(); }; let request = match &action { + Action::SelectThread(id) => { + *selected = id.clone(); + if let Err(error) = + threads.select(id.as_deref(), &mut session, &mut sink).await + { + return error; + } + continue; + } Action::SetProjectPinned { project_id, pinned } => session .request::(&pin_mutation(project_id, *pinned, command_id())) .map(|(id, frame)| (id, frame, None)), @@ -320,6 +351,9 @@ async fn serve( } } for event in reaction.events { + let Some(event) = threads.route(event, status) else { + continue; + }; if config_request.as_ref().is_some_and(|id| owns(&event, id)) { config_request = None; publish_catalog(event, status); @@ -343,6 +377,109 @@ async fn serve( } } +type Sink = SplitSink; + +/// The open thread subscription, and the interrupted ones whose last frames still arrive. +#[derive(Default)] +struct Threads { + open: Option, + stale: HashSet, +} + +struct OpenThread { + thread_id: String, + request_id: String, + state: ThreadState, +} + +impl Threads { + /// Switches the subscription to `wanted`, interrupting the previous one. + async fn select( + &mut self, + wanted: Option<&str>, + session: &mut Session, + sink: &mut Sink, + ) -> Result<(), String> { + if self.open.as_ref().map(|open| open.thread_id.as_str()) == wanted { + return Ok(()); + } + if let Some(open) = self.open.take() { + let frame = session.interrupt(&open.request_id); + self.stale.insert(open.request_id); + sink.send(Message::text(frame)) + .await + .map_err(|error| error.to_string())?; + } + let Some(thread_id) = wanted else { + return Ok(()); + }; + let payload = SubscribeThreadPayload { + accept_bounded_snapshot: None, + accept_compact_ids: None, + accept_compact_turn_items: None, + accept_deduped_bodies: None, + accept_deferred_work: None, + after_sequence: None, + request_completion_marker: None, + thread_id: thread_id.to_owned(), + }; + let (request_id, frame) = session.request::(&payload)?; + self.open = Some(OpenThread { + thread_id: thread_id.to_owned(), + request_id, + state: ThreadState::default(), + }); + sink.send(Message::text(frame)) + .await + .map_err(|error| error.to_string()) + } + + /// Consumes the events of thread requests, publishing the transcript; hands back the rest. + fn route(&mut self, event: Event, status: &StatusSender) -> Option { + let id = match &event { + Event::Item { request_id, .. } + | Event::Done { request_id, .. } + | Event::Failed { request_id, .. } => request_id.clone(), + _ => return Some(event), + }; + if self.stale.contains(&id) { + if !matches!(event, Event::Item { .. }) { + self.stale.remove(&id); + } + return None; + } + let Some(open) = self.open.as_mut().filter(|open| open.request_id == id) else { + return Some(event); + }; + let thread_id = open.thread_id.clone(); + match event { + Event::Item { value, .. } => { + if open.state.apply(&value) && open.state.is_seeded() { + publish( + status, + Status::Thread { + id: thread_id, + transcript: Ok(open.state.entries()), + }, + ); + } + } + Event::Failed { message, .. } => { + self.open = None; + publish( + status, + Status::Thread { + id: thread_id, + transcript: Err(message), + }, + ); + } + _ => self.open = None, + } + None + } +} + fn command_id() -> String { let mut bytes = [0u8; 8]; // A failed read leaves zeros, and the server rejects a repeated command id rather than applying it twice. @@ -475,6 +612,70 @@ mod tests { assert!(!settle(&mut pending, &failed("7"))); } + #[test] + fn thread_events_are_routed_and_interrupted_ones_swallowed() { + let (status, published) = async_channel::unbounded(); + let item = |id: &str, value: serde_json::Value| Event::Item { + request_id: id.to_owned(), + value, + }; + let snapshot = serde_json::json!({ + "kind": "snapshot", + "snapshotSequence": 1, + "projection": {"visibleTurnItems": [{ + "visibility": "local", + "item": {"id": "u", "ordinal": 1, "type": "user_message", "text": "hi"}, + }]}, + }); + let mut threads = Threads { + open: Some(OpenThread { + thread_id: "t1".to_owned(), + request_id: "5".to_owned(), + state: ThreadState::default(), + }), + stale: HashSet::from(["4".to_owned()]), + }; + // Other requests pass through, and a stale stream's frames never reach the shell. + assert!( + threads + .route(item("9", snapshot.clone()), &status) + .is_some() + ); + assert!( + threads + .route(item("4", snapshot.clone()), &status) + .is_none() + ); + let done = Event::Done { + request_id: "4".to_owned(), + value: serde_json::Value::Null, + }; + assert!(threads.route(done, &status).is_none()); + assert!(threads.stale.is_empty()); + assert!(published.try_recv().is_err()); + assert!(threads.route(item("5", snapshot), &status).is_none()); + let Ok(Status::Thread { id, transcript }) = published.try_recv() else { + panic!("the open thread publishes its transcript"); + }; + assert_eq!( + (id.as_str(), transcript.map(|rows| rows.len())), + ("t1", Ok(1)) + ); + let failed = Event::Failed { + request_id: "5".to_owned(), + message: "gone".to_owned(), + }; + assert!(threads.route(failed, &status).is_none()); + assert!(threads.open.is_none()); + assert!(matches!( + published.try_recv(), + Ok(Status::Thread { + transcript: Err(_), + .. + }) + )); + } + #[test] fn socket_url_carries_protocol_and_escaped_ticket() { let url = socket_url("http://127.0.0.1:13773", "a b&c").expect("url"); diff --git a/apps/desktop-gpui/src/history.rs b/apps/desktop-gpui/src/history.rs index a80e44a65816..dc2a3bef6ba7 100644 --- a/apps/desktop-gpui/src/history.rs +++ b/apps/desktop-gpui/src/history.rs @@ -1,21 +1,29 @@ -/// Back/forward over visited projects, the GPUI stand-in for the web's route history. -#[derive(Default)] -pub struct History { - visited: Vec, +/// Back/forward over visited routes, the GPUI stand-in for the web's route history. +pub struct History { + visited: Vec, index: usize, } -impl History { +impl Default for History { + fn default() -> Self { + Self { + visited: Vec::new(), + index: 0, + } + } +} + +impl History { /// Like a route push: drops anything ahead and ignores revisiting the current entry. - pub fn visit(&mut self, id: String) { - if self.current() == Some(id.as_str()) { + pub fn visit(&mut self, route: T) { + if self.current() == Some(&route) { return; } if !self.visited.is_empty() { self.visited.truncate(self.index + 1); self.index += 1; } - self.visited.push(id); + self.visited.push(route); } pub fn can_back(&self) -> bool { @@ -26,18 +34,18 @@ impl History { self.index + 1 < self.visited.len() } - pub fn back(&mut self) -> Option<&str> { + pub fn back(&mut self) -> Option<&T> { self.can_back().then(|| self.index -= 1)?; self.current() } - pub fn forward(&mut self) -> Option<&str> { + pub fn forward(&mut self) -> Option<&T> { self.can_forward().then(|| self.index += 1)?; self.current() } - fn current(&self) -> Option<&str> { - self.visited.get(self.index).map(String::as_str) + fn current(&self) -> Option<&T> { + self.visited.get(self.index) } } @@ -47,19 +55,19 @@ mod tests { #[test] fn walks_back_and_forward_and_a_new_visit_drops_the_forward_entries() { - let mut history = History::default(); + let mut history = History::<&str>::default(); assert!(!history.can_back() && !history.can_forward()); - history.visit("a".into()); - history.visit("a".into()); - history.visit("b".into()); - history.visit("c".into()); - assert_eq!(history.back(), Some("b")); - assert_eq!(history.back(), Some("a")); + history.visit("a"); + history.visit("a"); + history.visit("b"); + history.visit("c"); + assert_eq!(history.back(), Some(&"b")); + assert_eq!(history.back(), Some(&"a")); assert_eq!(history.back(), None); - assert_eq!(history.forward(), Some("b")); - history.visit("d".into()); + assert_eq!(history.forward(), Some(&"b")); + history.visit("d"); assert!(!history.can_forward()); - assert_eq!(history.back(), Some("b")); - assert_eq!(history.back(), Some("a")); + assert_eq!(history.back(), Some(&"b")); + assert_eq!(history.back(), Some(&"a")); } } diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 829e95bc83e6..126256d7d0f5 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -13,6 +13,7 @@ mod server; mod shell; mod status; mod strip; +mod thread; mod ui; mod wire; diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs index ee6313eb4c18..479b2d55e4c3 100644 --- a/apps/desktop-gpui/src/rpc.rs +++ b/apps/desktop-gpui/src/rpc.rs @@ -26,6 +26,16 @@ impl Method for SubscribeShell { type Success = wire::ShellItem; } +/// `orchestration.subscribeThread`, a stream of one thread's snapshot and events. The success +/// type is opaque in the schema; `thread::ThreadState` reads the items. +pub struct SubscribeThread; + +impl Method for SubscribeThread { + const TAG: &'static str = "orchestration.subscribeThread"; + type Payload = wire::SubscribeThreadPayload; + type Success = Value; +} + /// `projects.mutate`, which carries `project.update` among its mutations. pub struct ProjectsMutate; @@ -157,6 +167,12 @@ impl Session { Ok((id, frame.to_string())) } + /// The frame that stops a stream, as `Fiber.interrupt` does on the web client. + pub fn interrupt(&mut self, id: &str) -> String { + self.open.remove(id); + json!({ "_tag": "Interrupt", "requestId": id }).to_string() + } + pub fn react(&mut self, text: &str) -> Reaction { let mut reaction = Reaction::default(); let Ok(frame) = serde_json::from_str::(text) else { @@ -240,6 +256,14 @@ mod tests { assert_eq!(second, "1"); } + #[test] + fn interrupt_frame_names_the_request() { + assert_eq!( + Session::new().interrupt("4"), + r#"{"_tag":"Interrupt","requestId":"4"}"# + ); + } + #[test] fn chunk_is_acked_once_and_yields_each_value() { let mut session = Session::new(); diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index 7c98c2bf8c0f..f22fa4008f40 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -1,6 +1,7 @@ use crate::projects::{EnvironmentProjects, MachineKind, ProjectEntry}; use crate::providers::Catalog; use crate::strip::Branch; +use crate::thread::Entry; use crate::wire::generated::{ThreadsEntryLastErrorClass, ThreadsEntryStatus}; use std::fmt; @@ -23,6 +24,11 @@ pub enum Status { Environment(EnvironmentProjects), /// The primary connection's provider instances, for the composer's model picker. Providers(Catalog), + /// The selected thread's transcript, or why it could not be loaded. + Thread { + id: String, + transcript: Result, String>, + }, /// The branch checked out in a workspace root; `None` when it is not a repository. Branch { root: String, @@ -156,6 +162,7 @@ impl fmt::Display for Status { Self::Failed(message) => f.write_str(message), Self::Environment(other) => write!(f, "Environment {}", other.label), Self::Providers(_) => f.write_str("Providers"), + Self::Thread { id, .. } => write!(f, "Thread {id}"), Self::Branch { root, .. } => write!(f, "Branch of {root}"), } } diff --git a/apps/desktop-gpui/src/thread.rs b/apps/desktop-gpui/src/thread.rs new file mode 100644 index 000000000000..5729c55ed5e8 --- /dev/null +++ b/apps/desktop-gpui/src/thread.rs @@ -0,0 +1,312 @@ +//! The transcript read model built from `orchestration.subscribeThread`, mirroring +//! `packages/client-runtime/src/state/orchestrationV2Projection.ts` for what this client shows: +//! messages and a one-line summary of tool work. The success type is opaque in the wire schema, +//! so items are read straight from the JSON and anything unreadable is skipped. + +use serde_json::Value; +use std::sync::Arc; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + User, + Assistant, + /// A command, file change or search, shown as one muted line. + Work, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Entry { + pub id: String, + pub kind: Kind, + pub text: Arc, +} + +struct Row { + entry: Entry, + ordinal: i64, + /// Inherited rows (from a forked thread) keep their place; only local rows are reordered. + local: bool, +} + +#[derive(Default)] +pub struct ThreadState { + rows: Vec, + sequence: i64, + seeded: bool, +} + +fn first_line(text: &str) -> &str { + text.lines().next().unwrap_or_default() +} + +/// The row an item shows as; `None` for the item types this client does not draw. +fn entry_of(item: &Value) -> Option { + let text = |key: &str| item.get(key).and_then(Value::as_str); + let (kind, text) = match item.get("type")?.as_str()? { + "user_message" => (Kind::User, text("text")?.to_owned()), + "assistant_message" => (Kind::Assistant, text("text")?.to_owned()), + "command_execution" => (Kind::Work, format!("$ {}", first_line(text("input")?))), + "file_change" => (Kind::Work, format!("Edited {}", text("fileName")?)), + "file_search" => (Kind::Work, "Searched files".to_owned()), + "web_search" => (Kind::Work, "Searched the web".to_owned()), + _ => return None, + }; + Some(Entry { + id: item.get("id")?.as_str()?.to_owned(), + kind, + text: text.into(), + }) +} + +fn row_of(item: &Value, local: bool) -> Option { + Some(Row { + entry: entry_of(item)?, + ordinal: item.get("ordinal")?.as_i64()?, + local, + }) +} + +impl ThreadState { + /// False until the first snapshot arrives. + pub fn is_seeded(&self) -> bool { + self.seeded + } + + /// The transcript in order. Messages that have not received text yet are left out. + pub fn entries(&self) -> Vec { + self.rows + .iter() + .map(|row| &row.entry) + .filter(|entry| !entry.text.is_empty()) + .cloned() + .collect() + } + + /// Applies one stream item. Returns whether the transcript may have changed. + pub fn apply(&mut self, item: &Value) -> bool { + match item.get("kind").and_then(Value::as_str) { + Some("snapshot") => { + let Some(sequence) = item.get("snapshotSequence").and_then(Value::as_i64) else { + return false; + }; + let rows = item.pointer("/projection/visibleTurnItems"); + self.rows = rows + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(|row| { + let local = row.get("visibility").and_then(Value::as_str) == Some("local"); + row_of(row.get("item")?, local) + }) + .collect(); + self.sequence = sequence; + self.seeded = true; + true + } + Some("event") => { + let Some(sequence) = item.get("sequence").and_then(Value::as_i64) else { + return false; + }; + if !self.advance(sequence) { + return false; + } + let event = &item["event"]; + // Streaming text arrives as whole-item updates, not deltas. + if event.get("type").and_then(Value::as_str) != Some("turn-item.updated") { + return false; + } + event + .get("payload") + .and_then(|payload| row_of(payload, true)) + .is_some_and(|row| self.upsert(row)) + } + Some("unknown-event") => { + if let Some(sequence) = item.get("sequence").and_then(Value::as_i64) { + self.advance(sequence); + } + false + } + _ => false, + } + } + + /// Updates land on the newest rows, so the search starts there. A moved item goes before the + /// first later local row, like `upsertVisibleTurnItem`. + fn upsert(&mut self, row: Row) -> bool { + let found = self.rows.iter().rposition(|r| r.entry.id == row.entry.id); + if let Some(index) = found { + if self.rows[index].ordinal == row.ordinal { + let changed = self.rows[index].entry != row.entry; + self.rows[index].entry = row.entry; + return changed; + } + self.rows.remove(index); + } + let at = self + .rows + .iter() + .position(|r| r.local && (r.ordinal, &r.entry.id) > (row.ordinal, &row.entry.id)) + .unwrap_or(self.rows.len()); + self.rows.insert(at, row); + true + } + + /// Events at or below the last applied sequence are stale. + fn advance(&mut self, sequence: i64) -> bool { + if sequence <= self.sequence { + return false; + } + self.sequence = sequence; + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn item(id: &str, ordinal: i64, kind: &str, text: &str) -> Value { + match kind { + "command_execution" => { + json!({"id": id, "ordinal": ordinal, "type": kind, "input": text}) + } + _ => json!({"id": id, "ordinal": ordinal, "type": kind, "text": text}), + } + } + + fn snapshot(sequence: i64, items: Vec<(&str, Value)>) -> Value { + let rows: Vec = items + .into_iter() + .map(|(visibility, item)| json!({"visibility": visibility, "item": item})) + .collect(); + json!({ + "kind": "snapshot", + "snapshotSequence": sequence, + "projection": {"visibleTurnItems": rows}, + }) + } + + fn updated(sequence: i64, payload: Value) -> Value { + json!({ + "kind": "event", + "sequence": sequence, + "event": {"type": "turn-item.updated", "payload": payload}, + }) + } + + fn texts(state: &ThreadState) -> Vec { + state.entries().iter().map(|e| e.text.to_string()).collect() + } + + fn seeded() -> ThreadState { + let mut state = ThreadState::default(); + assert!(!state.is_seeded()); + state.apply(&snapshot( + 10, + vec![ + ("local", item("u1", 1, "user_message", "hi")), + ("local", item("a1", 2, "assistant_message", "hello")), + ("local", item("r1", 3, "reasoning", "thinking")), + ], + )); + state + } + + #[test] + fn snapshot_seeds_messages_and_skips_items_it_does_not_draw() { + let state = seeded(); + assert!(state.is_seeded()); + let kinds: Vec<_> = state.entries().iter().map(|e| e.kind).collect(); + assert_eq!(kinds, vec![Kind::User, Kind::Assistant]); + assert_eq!(texts(&state), vec!["hi", "hello"]); + } + + #[test] + fn streaming_updates_replace_the_item_text_in_place() { + let mut state = seeded(); + assert!(state.apply(&updated( + 11, + item("a1", 2, "assistant_message", "hello wor") + ))); + assert!(state.apply(&updated( + 12, + item("a1", 2, "assistant_message", "hello world") + ))); + assert_eq!(texts(&state), vec!["hi", "hello world"]); + // The same text again changes nothing. + assert!(!state.apply(&updated( + 13, + item("a1", 2, "assistant_message", "hello world") + ))); + } + + #[test] + fn new_items_land_by_ordinal_and_work_shows_as_a_line() { + let mut state = seeded(); + assert!(state.apply(&updated(11, item("a2", 5, "assistant_message", "done")))); + assert!(state.apply(&updated( + 12, + item("c1", 4, "command_execution", "ls -la\nmore") + ))); + assert_eq!(texts(&state), vec!["hi", "hello", "$ ls -la", "done"]); + assert_eq!(state.entries()[2].kind, Kind::Work); + } + + #[test] + fn an_empty_streaming_message_stays_hidden_until_it_has_text() { + let mut state = seeded(); + assert!(state.apply(&updated(11, item("a2", 5, "assistant_message", "")))); + assert_eq!(texts(&state), vec!["hi", "hello"]); + state.apply(&updated(12, item("a2", 5, "assistant_message", "x"))); + assert_eq!(texts(&state), vec!["hi", "hello", "x"]); + } + + #[test] + fn events_at_or_below_the_last_sequence_are_ignored() { + let mut state = seeded(); + assert!(!state.apply(&updated(10, item("a1", 2, "assistant_message", "stale")))); + assert!(!state.apply(&updated(3, item("a9", 9, "assistant_message", "old")))); + assert_eq!(texts(&state), vec!["hi", "hello"]); + assert!(state.apply(&updated(11, item("a1", 2, "assistant_message", "fresh")))); + assert!(!state.apply(&updated(11, item("a1", 2, "assistant_message", "again")))); + assert_eq!(texts(&state), vec!["hi", "fresh"]); + } + + #[test] + fn unknown_events_advance_the_cursor_and_other_events_change_nothing() { + let mut state = seeded(); + assert!(!state.apply(&json!({"kind": "unknown-event", "sequence": 11, "eventType": "x"}))); + assert!(!state.apply(&updated(11, item("a1", 2, "assistant_message", "late")))); + let message = json!({"kind": "event", "sequence": 12, "event": {"type": "run.updated"}}); + assert!(!state.apply(&message)); + assert!(!state.apply(&json!({"kind": "synchronized"}))); + assert_eq!(texts(&state), vec!["hi", "hello"]); + } + + #[test] + fn a_later_snapshot_replaces_everything() { + let mut state = seeded(); + state.apply(&snapshot( + 4, + vec![("local", item("u2", 1, "user_message", "again"))], + )); + assert_eq!(texts(&state), vec!["again"]); + assert!(state.apply(&updated(5, item("a3", 2, "assistant_message", "ok")))); + assert_eq!(texts(&state), vec!["again", "ok"]); + } + + #[test] + fn inherited_rows_keep_their_place_when_a_local_item_is_inserted() { + let mut state = ThreadState::default(); + state.apply(&snapshot( + 1, + vec![ + ("inherited", item("u1", 9, "user_message", "parent")), + ("local", item("u2", 3, "user_message", "child")), + ], + )); + state.apply(&updated(2, item("a1", 2, "assistant_message", "first"))); + assert_eq!(texts(&state), vec!["parent", "first", "child"]); + } +} diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 73cf90846a40..4e8a8272ea24 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -8,11 +8,12 @@ use crate::strip::{ self, Branch, Defaults, EnvMode, HOST_SHORTCUT, PERMISSIONS_SHORTCUT, RuntimeMode, WORKSPACE_SHORTCUT, }; +use crate::thread::{Entry, Kind}; use gpui::{ Anchor, Animation, AnimationExt as _, AnyElement, App, Bounds, Context, Entity, Focusable as _, - FontWeight, Hsla, IntoElement, Keystroke, ObjectFit, ParentElement, Pixels, Render, RenderOnce, - Styled, UniformListScrollHandle, Window, actions, canvas, div, ease_in_out, img, prelude::*, - px, rgb, uniform_list, + FollowMode, FontWeight, Hsla, IntoElement, Keystroke, ListAlignment, ListState, ObjectFit, + ParentElement, Pixels, Render, RenderOnce, Styled, UniformListScrollHandle, Window, actions, + canvas, div, ease_in_out, img, list, prelude::*, px, rgb, uniform_list, }; use gpui_component::{ ActiveTheme, Icon, IconName, Selectable, Sizable, TitleBar, @@ -87,6 +88,19 @@ fn toggled(narrow: bool, open: bool, overlay_open: bool) -> (bool, bool) { /// A thread card (`h-[4.875rem]`) between the `py-0.5` of its list item. const CARD_HEIGHT: f32 = 78.; const ROW_HEIGHT: f32 = CARD_HEIGHT + 4.; +/// Where the window is: the project in the picker and the thread open in the main area, if any. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct Route { + project: Option, + thread: Option, +} + +/// The open thread's transcript as last published by the connection. +struct Transcript { + id: String, + result: Result, String>, +} + const NO_PROJECT: &str = "No project"; const HEADLINE_SCRATCH: &str = "What should we work on?"; /// The web's `ComboboxPopup` cap, `max-h-[min(var(--available-height),23rem)]`. @@ -111,13 +125,14 @@ pub struct MainView { /// Hover styles cannot change layout, so the send button's hover growth is view state. send_hovered: bool, permissions_open: bool, - /// The open thread; selecting is all a row does yet. selected_thread: Option, + transcript: Option, + transcript_list: ListState, /// Local view state until the draft's mode is sent to the server. runtime_mode: Option, permissions: Entity, selected_project: Option, - history: History, + history: History, picker_open: bool, /// Last painted trigger bounds, so the popup can shrink to fit below it as on the web. picker_trigger: Rc>>, @@ -179,6 +194,9 @@ impl MainView { send_hovered: false, permissions_open: false, selected_thread: None, + transcript: None, + // Overdraw keeps a screenful of rows measured beyond each edge. + transcript_list: ListState::new(0, ListAlignment::Top, px(600.)), runtime_mode: None, permissions, selected_project: None, @@ -206,6 +224,10 @@ impl MainView { self.providers = Some(catalog); return cx.notify(); } + if let Status::Thread { id, transcript } = status { + self.set_transcript(id, transcript); + return cx.notify(); + } if let Status::Branch { root, branch } = status { self.branches.insert(root, branch); return cx.notify(); @@ -221,13 +243,79 @@ impl MainView { .and_then(|shell| active_project(None, shell)) .map(|entry| entry.id.clone()); if let Some(id) = &self.selected_project { - self.history.visit(id.clone()); + self.history.visit(Route { + project: Some(id.clone()), + thread: None, + }); } } self.request_branch(); cx.notify(); } + /// Keeps the rows of the open thread; an update for any other thread is stale. + fn set_transcript(&mut self, id: String, result: Result, String>) { + if self.selected_thread.as_deref() != Some(id.as_str()) { + return; + } + let count = |result: &Result, String>| result.as_ref().map_or(0, Vec::len); + let previous = self.transcript.take().filter(|t| t.id == id); + match &previous { + // Rows that streamed keep their measured height; only the changed tail is remeasured. + Some(previous) => { + let same = match (&previous.result, &result) { + (Ok(old), Ok(new)) => old.iter().zip(new).take_while(|(a, b)| a == b).count(), + _ => 0, + }; + let (old, new) = (count(&previous.result), count(&result)); + self.transcript_list.splice(same..old, new - same); + } + None => { + self.transcript_list.reset(count(&result)); + self.transcript_list.set_follow_mode(FollowMode::Tail); + } + } + self.transcript = Some(Transcript { id, result }); + } + + /// Points the connection's thread subscription at the selection. + fn sync_thread(&mut self) { + if self.transcript.as_ref().map(|t| &t.id) != self.selected_thread.as_ref() { + self.transcript = None; + } + let action = Action::SelectThread(self.selected_thread.clone()); + if self.actions.try_send(action).is_err() { + eprintln!("[ui] could not ask for the thread"); + } + } + + fn apply_route(&mut self, route: Route, cx: &mut Context) { + self.sidebar_overlay_open = false; + self.selected_project = route.project; + self.selected_thread = route.thread; + self.sync_thread(); + self.request_branch(); + cx.notify(); + } + + /// Opens a thread in its project, or the empty new-thread state for `None`. + fn open_thread(&mut self, id: Option, cx: &mut Context) { + let project = self.shell().and_then(|shell| { + let row = shell.rows.iter().find(|row| Some(&row.id) == id.as_ref())?; + let entry = shell + .project_entries + .iter() + .find(|entry| entry.contains(&row.project_id))?; + Some(entry.id.clone()) + }); + let route = Route { + project: project.or_else(|| self.selected_project.clone()), + thread: id, + }; + self.history.visit(route.clone()); + self.apply_route(route, cx); + } + /// Asks for the branch of the active project's root once per connection. fn request_branch(&mut self) { let Some(entry) = self @@ -271,6 +359,11 @@ impl MainView { .shell() .and_then(|shell| active_project(self.selected_project.as_deref(), shell)); let open = self.sidebar_open && !narrow; + let title = self + .shell() + .zip(self.selected_thread.as_deref()) + .and_then(|(shell, id)| shell.rows.iter().find(|row| row.id == id)) + .map_or("New thread", |row| row.title.as_str()); TitleBar::new() .h(px(TOPBAR_HEIGHT)) .pl_0() @@ -286,7 +379,7 @@ impl MainView { .border_b_1() .border_color(border) .when(self.shell().is_some(), |row| { - row.child(self.breadcrumb(active, cx)) + row.child(self.breadcrumb(active, title, cx)) }), ) } @@ -370,30 +463,26 @@ impl MainView { }) } + /// Picking a project starts a new thread in it. fn select_project(&mut self, id: String, cx: &mut Context) { - self.history.visit(id.clone()); - self.selected_project = Some(id); + let route = Route { + project: Some(id), + thread: None, + }; + self.history.visit(route.clone()); self.picker_open = false; - self.sidebar_overlay_open = false; - self.request_branch(); - cx.notify(); + self.apply_route(route, cx); } fn go_back(&mut self, cx: &mut Context) { - if let Some(id) = self.history.back() { - self.sidebar_overlay_open = false; - self.selected_project = Some(id.to_owned()); - self.request_branch(); - cx.notify(); + if let Some(route) = self.history.back().cloned() { + self.apply_route(route, cx); } } fn go_forward(&mut self, cx: &mut Context) { - if let Some(id) = self.history.forward() { - self.sidebar_overlay_open = false; - self.selected_project = Some(id.to_owned()); - self.request_branch(); - cx.notify(); + if let Some(route) = self.history.forward().cloned() { + self.apply_route(route, cx); } } @@ -533,7 +622,7 @@ impl MainView { .child(self.sidebar_footer(cx)) } - /// A sidebar row; picking it marks the open thread, and closes the overlay like the web. + /// A sidebar row; picking it opens the thread, and closes the overlay like the web. fn thread_item(&self, row: &ThreadRow, cx: &mut Context) -> gpui::Stateful { let selected = self.selected_thread.as_deref() == Some(row.id.as_str()); let id = row.id.clone(); @@ -555,11 +644,7 @@ impl MainView { self.sidebar_width.get(), cx, ) - .on_click(cx.listener(move |this, _, _, cx| { - this.selected_thread = Some(id.clone()); - this.sidebar_overlay_open = false; - cx.notify(); - })) + .on_click(cx.listener(move |this, _, _, cx| this.open_thread(Some(id.clone()), cx))) } /// One collapsible shelf: its header, then its rows while expanded. @@ -698,12 +783,15 @@ impl MainView { cx, )) }) - .child(header_icon_button( - "new-thread", - Icons::SquarePen, - format!("New thread ({new_thread})").into(), - cx, - )) + .child( + header_icon_button( + "new-thread", + Icons::SquarePen, + format!("New thread ({new_thread})").into(), + cx, + ) + .on_click(cx.listener(|this, _, _, cx| this.open_thread(None, cx))), + ) } fn main_area(&self, cx: &mut Context) -> AnyElement { @@ -716,6 +804,23 @@ impl MainView { let active = active_project(self.selected_project.as_deref(), shell); let scratch = active.is_some_and(|entry| entry.scratch); let project = active.map_or_else(|| "your project".to_owned(), |entry| entry.title.clone()); + if self.selected_thread.is_some() { + return v_flex() + .flex_1() + .min_w_0() + .h_full() + .child(self.transcript_view(cx)) + .child( + v_flex() + .w_full() + .items_center() + .px_6() + .pb_4() + .child(self.composer_box(cx)) + .children(active.and_then(|entry| self.context_strip(entry, cx))), + ) + .into_any_element(); + } v_flex() .flex_1() .min_w_0() @@ -741,9 +846,82 @@ impl MainView { .into_any_element() } + /// The open thread's rows, or why there are none yet. + fn transcript_view(&self, cx: &mut Context) -> AnyElement { + let note = |text: String| { + centered(cx) + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(text) + .into_any_element() + }; + match self.transcript.as_ref().map(|t| &t.result) { + None => note("Loading...".to_owned()), + Some(Err(message)) => note(format!("Could not load this thread: {message}")), + Some(Ok(entries)) if entries.is_empty() => note("No messages yet".to_owned()), + Some(Ok(_)) => div() + .flex_1() + .min_h_0() + .w_full() + .child( + list( + self.transcript_list.clone(), + cx.processor(|this, ix, _, cx| this.transcript_item(ix, cx)), + ) + .size_full(), + ) + .into_any_element(), + } + } + + /// One row in the web's 720px column: the user's words in a bubble on the right, the + /// assistant's plain, tool work as a muted line. + fn transcript_item(&self, ix: usize, cx: &App) -> AnyElement { + let Some(entry) = self + .transcript + .as_ref() + .and_then(|t| t.result.as_ref().ok()) + .and_then(|entries| entries.get(ix)) + else { + return div().into_any_element(); + }; + let theme = cx.theme(); + let text: gpui::SharedString = entry.text.to_string().into(); + let body = match entry.kind { + Kind::User => h_flex().justify_end().child( + div() + .max_w(px(560.)) + .px_3() + .py_2() + .rounded(px(16.)) + .bg(theme.secondary) + .text_sm() + .child(text), + ), + Kind::Assistant => h_flex().child(div().min_w_0().text_sm().child(text)), + Kind::Work => h_flex().child( + div() + .min_w_0() + .truncate() + .text_xs() + .font_family(theme.mono_font_family.clone()) + .text_color(theme.muted_foreground) + .child(text), + ), + }; + h_flex() + .w_full() + .justify_center() + .px_6() + .py_2() + .child(div().w_full().max_w(px(720.)).child(body)) + .into_any_element() + } + fn breadcrumb( &self, active: Option<&ProjectEntry>, + title: &str, cx: &mut Context, ) -> impl IntoElement { let theme = cx.theme(); @@ -787,6 +965,10 @@ impl MainView { "New thread in {}", project_label(Some(entry)) ))) + .on_click(cx.listener({ + let id = entry.id.clone(); + move |this, _, _, cx| this.select_project(id.clone(), cx) + })) .child(icon) .child( div() @@ -803,7 +985,12 @@ impl MainView { .text_sm() .font_weight(FontWeight::MEDIUM) .children(project) - .child(div().text_color(foreground).child("New thread")) + .child( + div() + .text_color(foreground) + .truncate() + .child(title.to_owned()), + ) } fn hero( @@ -1556,7 +1743,7 @@ fn header_icon_button( icon: gpui_kit_assets::IconName, label: gpui::SharedString, cx: &App, -) -> impl IntoElement + use<> { +) -> gpui::Stateful { ghost_icon_button(id, Icon::new(icon).size_4(), label, true, cx) } diff --git a/apps/desktop-gpui/src/wire/generated.rs b/apps/desktop-gpui/src/wire/generated.rs index 2e7d70feef5e..f1a06c86b9d9 100644 --- a/apps/desktop-gpui/src/wire/generated.rs +++ b/apps/desktop-gpui/src/wire/generated.rs @@ -705,6 +705,27 @@ for OrchestrationGetArchivedShellSnapshotError { Self::EnvironmentAuthorizationErrorEncoded(value) } } +///`OrchestrationGetThreadProjectionError` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +#[serde(untagged)] +pub enum OrchestrationGetThreadProjectionError { + OrchestrationV2GetThreadProjectionErrorEncoded( + OrchestrationV2GetThreadProjectionErrorEncoded, + ), + EnvironmentAuthorizationErrorEncoded(EnvironmentAuthorizationErrorEncoded), +} +impl ::std::convert::From +for OrchestrationGetThreadProjectionError { + fn from(value: OrchestrationV2GetThreadProjectionErrorEncoded) -> Self { + Self::OrchestrationV2GetThreadProjectionErrorEncoded(value) + } +} +impl ::std::convert::From +for OrchestrationGetThreadProjectionError { + fn from(value: EnvironmentAuthorizationErrorEncoded) -> Self { + Self::EnvironmentAuthorizationErrorEncoded(value) + } +} ///`OrchestrationSubscribeShellPayload` #[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug, Default)] pub struct OrchestrationSubscribeShellPayload { @@ -1222,6 +1243,67 @@ for OrchestrationSubscribeShellSuccessVariant5Location { value.parse() } } +///`OrchestrationSubscribeThreadPayload` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct OrchestrationSubscribeThreadPayload { + #[serde( + rename = "acceptBoundedSnapshot", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub accept_bounded_snapshot: ::std::option::Option, + #[serde( + rename = "acceptCompactIds", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub accept_compact_ids: ::std::option::Option, + #[serde( + rename = "acceptCompactTurnItems", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub accept_compact_turn_items: ::std::option::Option, + #[serde( + rename = "acceptDedupedBodies", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub accept_deduped_bodies: ::std::option::Option, + #[serde( + rename = "acceptDeferredWork", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub accept_deferred_work: ::std::option::Option, + #[serde( + rename = "afterSequence", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub after_sequence: ::std::option::Option, + #[serde( + rename = "requestCompletionMarker", + skip_serializing_if = "::std::option::Option::is_none" + )] + pub request_completion_marker: ::std::option::Option, + #[serde(rename = "threadId")] + pub thread_id: ::std::string::String, +} +///`OrchestrationSubscribeThreadSuccess` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +#[serde(transparent)] +pub struct OrchestrationSubscribeThreadSuccess(pub ::serde_json::Value); +impl ::std::ops::Deref for OrchestrationSubscribeThreadSuccess { + type Target = ::serde_json::Value; + fn deref(&self) -> &::serde_json::Value { + &self.0 + } +} +impl ::std::convert::From for ::serde_json::Value { + fn from(value: OrchestrationSubscribeThreadSuccess) -> Self { + value.0 + } +} +impl ::std::convert::From<::serde_json::Value> for OrchestrationSubscribeThreadSuccess { + fn from(value: ::serde_json::Value) -> Self { + Self(value) + } +} ///`OrchestrationV2GetShellSnapshotErrorEncoded` #[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] pub struct OrchestrationV2GetShellSnapshotErrorEncoded { @@ -1286,6 +1368,73 @@ for OrchestrationV2GetShellSnapshotErrorEncodedTag { value.parse() } } +///`OrchestrationV2GetThreadProjectionErrorEncoded` +#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct OrchestrationV2GetThreadProjectionErrorEncoded { + #[serde(skip_serializing_if = "::std::option::Option::is_none")] + pub cause: ::std::option::Option<::serde_json::Value>, + pub message: ::std::string::String, + #[serde(rename = "_tag")] + pub tag: OrchestrationV2GetThreadProjectionErrorEncodedTag, + #[serde(rename = "threadId")] + pub thread_id: ::std::string::String, +} +///`OrchestrationV2GetThreadProjectionErrorEncodedTag` +#[derive( + ::serde::Deserialize, + ::serde::Serialize, + Clone, + Copy, + Debug, + Eq, + Hash, + Ord, + PartialEq, + PartialOrd +)] +pub enum OrchestrationV2GetThreadProjectionErrorEncodedTag { + OrchestrationV2GetThreadProjectionError, +} +impl ::std::fmt::Display for OrchestrationV2GetThreadProjectionErrorEncodedTag { + fn fmt(&self, f: &mut ::std::fmt::Formatter<'_>) -> ::std::fmt::Result { + match *self { + Self::OrchestrationV2GetThreadProjectionError => { + f.write_str("OrchestrationV2GetThreadProjectionError") + } + } + } +} +impl ::std::str::FromStr for OrchestrationV2GetThreadProjectionErrorEncodedTag { + type Err = self::error::ConversionError; + fn from_str( + value: &str, + ) -> ::std::result::Result { + match value { + "OrchestrationV2GetThreadProjectionError" => { + Ok(Self::OrchestrationV2GetThreadProjectionError) + } + _ => Err("invalid value".into()), + } + } +} +impl ::std::convert::TryFrom<&str> +for OrchestrationV2GetThreadProjectionErrorEncodedTag { + type Error = self::error::ConversionError; + fn try_from( + value: &str, + ) -> ::std::result::Result { + value.parse() + } +} +impl ::std::convert::TryFrom<::std::string::String> +for OrchestrationV2GetThreadProjectionErrorEncodedTag { + type Error = self::error::ConversionError; + fn try_from( + value: ::std::string::String, + ) -> ::std::result::Result { + value.parse() + } +} ///`PendingBackgroundTasks` #[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] #[serde(transparent)] diff --git a/apps/desktop-gpui/src/wire/mod.rs b/apps/desktop-gpui/src/wire/mod.rs index 6a918bb921fe..3fa68ec22ef8 100644 --- a/apps/desktop-gpui/src/wire/mod.rs +++ b/apps/desktop-gpui/src/wire/mod.rs @@ -6,7 +6,8 @@ pub mod generated; pub use generated::{ OrchestrationSubscribeShellPayload as SubscribeShellPayload, - OrchestrationSubscribeShellSuccess as ShellItem, ProjectsEntry as Project, + OrchestrationSubscribeShellSuccess as ShellItem, + OrchestrationSubscribeThreadPayload as SubscribeThreadPayload, ProjectsEntry as Project, ProjectsMutatePayload as ProjectMutation, ProjectsMutatePayloadVariant1Type as ProjectMutationType, ProjectsMutateSuccess, ThreadsEntry as Thread, diff --git a/apps/desktop-gpui/tests/wire_types.rs b/apps/desktop-gpui/tests/wire_types.rs index 658107540796..036cd8c66e4d 100644 --- a/apps/desktop-gpui/tests/wire_types.rs +++ b/apps/desktop-gpui/tests/wire_types.rs @@ -9,7 +9,11 @@ use std::path::PathBuf; /// RPC methods whose payload, success and error types are generated. Add a method here when the /// client starts calling it. -const METHODS: &[&str] = &["orchestration.subscribeShell", "projects.mutate"]; +const METHODS: &[&str] = &[ + "orchestration.subscribeShell", + "orchestration.subscribeThread", + "projects.mutate", +]; /// Properties typify cannot represent, kept as `serde_json::Value`: (definition, property). /// Gaps are listed in the README. From 3890c74529b752e9589826b2d00d70ca77a334e2 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 09:29:53 -0500 Subject: [PATCH 16/82] fix(desktop-gpui): sidebar shows the same threads as the web --- apps/desktop-gpui/src/shell.rs | 56 +++++++++++++++++++++++++++++----- 1 file changed, 48 insertions(+), 8 deletions(-) diff --git a/apps/desktop-gpui/src/shell.rs b/apps/desktop-gpui/src/shell.rs index ef9e9942c5fd..b04e0b938c57 100644 --- a/apps/desktop-gpui/src/shell.rs +++ b/apps/desktop-gpui/src/shell.rs @@ -4,16 +4,17 @@ use crate::projects; use crate::status::{PrBadge, PrState, Section, ShellView, ThreadRow, TopStatus}; use crate::wire::generated::{ - OrchestrationSubscribeShellSuccess as Item, - OrchestrationSubscribeShellSuccessVariant4Location as Location, - OrchestrationSubscribeShellSuccessVariant5Location as RemovedLocation, -}; -use crate::wire::generated::{ - PullRequestsItem, PullRequestsItemSource as LinkSource, SnapshotState as PrSnapshotState, + LineageRelationshipToParent as LineageRelationship, PullRequestsItem, + PullRequestsItemSource as LinkSource, SnapshotState as PrSnapshotState, ThreadsEntryActivityRunStatus as Activity, ThreadsEntryInteractionMode as InteractionMode, ThreadsEntryPendingRuntimeRequestKind as RequestKind, ThreadsEntrySettledOverride as Override, ThreadsEntryStatus as RunStatus, }; +use crate::wire::generated::{ + OrchestrationSubscribeShellSuccess as Item, + OrchestrationSubscribeShellSuccessVariant4Location as Location, + OrchestrationSubscribeShellSuccessVariant5Location as RemovedLocation, +}; use crate::wire::{Project, Thread}; use std::cmp::Ordering; use std::collections::HashMap; @@ -199,6 +200,14 @@ fn section_of(thread: &Thread, now_ms: i64) -> Section { } } +/// `isListedThreadLineage` in `packages/contracts/src/orchestrationV2.ts`: subagents and side +/// chats live inside their parent's view, never in the sidebar. The web also hides tasks linked +/// environments run for a listed thread (`delegatedUnderParent`); this client does not track them. +fn is_listed(thread: &Thread) -> bool { + thread.lineage.relationship_to_parent != Some(LineageRelationship::Subagent) + && thread.lineage.side_chat != Some(true) +} + fn newest_first(a: (i64, &Thread), b: (i64, &Thread)) -> Ordering { b.0.cmp(&a.0).then_with(|| a.1.id.cmp(&b.1.id)) } @@ -380,7 +389,7 @@ impl ShellState { /// default settings: Working shelf on, no project folders, nothing dragged. pub fn view_at(&self, now_ms: i64) -> ShellView { let mut sections: [Vec<&Thread>; 5] = Default::default(); - for thread in self.threads.values() { + for thread in self.threads.values().filter(|thread| is_listed(thread)) { sections[section_of(thread, now_ms) as usize].push(thread); } let [pinned, active, working, snoozed, settled] = &mut sections; @@ -421,7 +430,10 @@ impl ShellState { ShellView { projects: self.project_count(), threads: self.thread_count(), - project_entries: projects::build_entries(self.projects.values(), self.threads.values()), + project_entries: projects::build_entries( + self.projects.values(), + self.threads.values().filter(|thread| is_listed(thread)), + ), rows, } } @@ -596,6 +608,34 @@ mod tests { } } + #[test] + fn subagents_and_side_chats_have_no_sidebar_row() { + let view = view_of(vec![ + thread("top", |_| {}), + thread("child", |t| { + t["lineage"] = serde_json::json!({ + "parentThreadId": "top", "relationshipToParent": "subagent", "rootThreadId": "top" + }); + set(t, &[("settledOverride", "settled".into())]); + }), + thread("chat", |t| { + t["lineage"] = serde_json::json!({ + "parentThreadId": "top", "relationshipToParent": null, + "rootThreadId": "top", "sideChat": true + }); + set(t, &[("pinnedAt", "2026-01-01T00:00:00.000Z".into())]); + }), + thread("forked", |t| { + t["lineage"] = serde_json::json!({ + "parentThreadId": "top", "relationshipToParent": "fork", "rootThreadId": "top" + }); + }), + ]); + let mut shown = ids(&view); + shown.sort_unstable(); + assert_eq!(shown, ["forked", "top"]); + } + #[test] fn the_pull_request_badge_follows_the_web() { let link = |number: u64, source: &str, state: Option<&str>| { From 9ed0de335d3f5f53a42c74e3815df545d4123cf3 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 10:33:42 -0500 Subject: [PATCH 17/82] fix(desktop-gpui): project enrichment refreshes no longer wipe the sidebar The server sends repository identity refreshes as shell snapshots with no threads and only the changed projects. The client treated every snapshot as authoritative, so the first refresh emptied the sidebar until threads trickled back through updates. Snapshots that name resolved repository roots now only update repository identities on known projects, mirroring mergeShellSnapshotProjects. Authoritative snapshots keep an identity the client already knew. --- apps/desktop-gpui/src/shell.rs | 103 ++++++++++++++++++++++++++++++--- 1 file changed, 96 insertions(+), 7 deletions(-) diff --git a/apps/desktop-gpui/src/shell.rs b/apps/desktop-gpui/src/shell.rs index b04e0b938c57..99359f9b381b 100644 --- a/apps/desktop-gpui/src/shell.rs +++ b/apps/desktop-gpui/src/shell.rs @@ -443,13 +443,29 @@ impl ShellState { match item { // The completion marker carries nothing the counts need. Item::Variant0 { .. } => false, - Item::Variant1 { snapshot, .. } => { - self.projects = snapshot - .projects - .0 - .into_iter() - .map(|p| (p.id.clone(), p)) - .collect(); + Item::Variant1 { + snapshot, + resolved_repository_identity_roots: resolved, + .. + } => { + // `authoritative = item.resolvedRepositoryIdentityRoots === undefined` in + // `shell.ts`; the server only sends non-empty root groups, so empty is absent. + if !resolved.is_empty() { + self.merge_enrichment(snapshot.projects.0, &resolved); + return true; + } + // `retainRepositoryIdentity`: a refresh that lost the identity keeps the known one. + let mut projects: HashMap = HashMap::new(); + for mut project in snapshot.projects.0 { + if project.repository_identity.is_none() + && let Some(prior) = self.projects.get(&project.id) + && prior.workspace_root == project.workspace_root + { + project.repository_identity = prior.repository_identity.clone(); + } + projects.insert(project.id.clone(), project); + } + self.projects = projects; self.threads = snapshot .threads .0 @@ -516,6 +532,26 @@ impl ShellState { } } + /// `mergeShellSnapshotProjects` for an enrichment refresh: only repository identities change, + /// on known projects with the same workspace root. Resolved roots take the identity exactly, + /// others only fill a missing one. Threads, sequence and the project set are untouched. + fn merge_enrichment(&mut self, refreshed: Vec, resolved: &[String]) { + for candidate in refreshed { + let Some(project) = self.projects.get_mut(&candidate.id) else { + continue; + }; + if project.workspace_root != candidate.workspace_root { + continue; + } + if resolved.contains(&project.workspace_root) + || (project.repository_identity.is_none() + && candidate.repository_identity.is_some()) + { + project.repository_identity = candidate.repository_identity; + } + } + } + /// Events at or below the last applied sequence are stale. fn advance(&mut self, sequence: i64) -> bool { if sequence <= self.sequence { @@ -560,6 +596,59 @@ mod tests { assert_eq!((state.project_count(), state.thread_count()), (1, 1)); } + #[test] + fn enrichment_refreshes_only_update_repository_identity() { + let mut state = seeded(); + let key = |state: &ShellState| { + state.projects["project-v2"] + .repository_identity + .as_ref() + .map(|identity| identity.canonical_key.clone()) + }; + let refresh = |roots: &[&str], root: &str| { + with(SNAPSHOT, |value| { + value["resolvedRepositoryIdentityRoots"] = roots.into(); + let snapshot = &mut value["snapshot"]; + snapshot["threads"] = serde_json::json!([]); + snapshot["snapshotSequence"] = 99.into(); + let project = &mut snapshot["projects"][0]; + project["workspaceRoot"] = root.into(); + project["repositoryIdentity"] = serde_json::json!({ + "canonicalKey": "github.com/o/r", "locator": {"remoteName": "origin", "remoteUrl": "u", "source": "git-remote"} + }); + }) + }; + // A different workspace root is not the same project. + state.apply(refresh(&["/elsewhere"], "/elsewhere")); + assert_eq!(key(&state), None); + // An unresolved root only fills a missing identity. + state.apply(refresh(&["/other"], "/workspace/project")); + assert_eq!(key(&state).as_deref(), Some("github.com/o/r")); + // The threads, project set and sequence survive every refresh. + assert_eq!((state.thread_count(), state.project_count()), (1, 1)); + assert_eq!(state.sequence, 5); + // A resolved root takes the identity exactly, including none. + state.apply(with(SNAPSHOT, |value| { + value["resolvedRepositoryIdentityRoots"] = serde_json::json!(["/workspace/project"]); + value["snapshot"]["threads"] = serde_json::json!([]); + })); + assert_eq!(key(&state), None); + assert_eq!(state.thread_count(), 1); + } + + #[test] + fn an_authoritative_snapshot_keeps_a_known_identity() { + let mut state = seeded(); + state.apply(with(SNAPSHOT, |value| { + value["resolvedRepositoryIdentityRoots"] = serde_json::json!(["/workspace/project"]); + value["snapshot"]["projects"][0]["repositoryIdentity"] = serde_json::json!({ + "canonicalKey": "k", "locator": {"remoteName": "origin", "remoteUrl": "u", "source": "git-remote"} + }); + })); + state.apply(item(SNAPSHOT)); + assert!(state.projects["project-v2"].repository_identity.is_some()); + } + #[test] fn updates_upsert_and_removes_delete() { let mut state = seeded(); From 010f1a7103882669324f9b8a13a83457b9c6b951 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 10:48:57 -0500 Subject: [PATCH 18/82] feat(desktop-gpui): drag the sidebar edge to resize it --- apps/desktop-gpui/src/ui.rs | 116 +++++++++++++++++++++++++++++++----- 1 file changed, 101 insertions(+), 15 deletions(-) diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 4e8a8272ea24..7fba6d1d0f75 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -10,10 +10,11 @@ use crate::strip::{ }; use crate::thread::{Entry, Kind}; use gpui::{ - Anchor, Animation, AnimationExt as _, AnyElement, App, Bounds, Context, Entity, Focusable as _, - FollowMode, FontWeight, Hsla, IntoElement, Keystroke, ListAlignment, ListState, ObjectFit, - ParentElement, Pixels, Render, RenderOnce, Styled, UniformListScrollHandle, Window, actions, - canvas, div, ease_in_out, img, list, prelude::*, px, rgb, uniform_list, + Anchor, Animation, AnimationExt as _, AnyElement, App, Bounds, Context, DragMoveEvent, Entity, + Focusable as _, FollowMode, FontWeight, Hsla, IntoElement, Keystroke, ListAlignment, ListState, + MouseButton, MouseDownEvent, ObjectFit, ParentElement, Pixels, Render, RenderOnce, Styled, + UniformListScrollHandle, Window, actions, canvas, div, ease_in_out, img, list, prelude::*, px, + rgb, uniform_list, }; use gpui_component::{ ActiveTheme, Icon, IconName, Selectable, Sizable, TitleBar, @@ -33,7 +34,14 @@ use std::{ time::Duration, }; -const SIDEBAR_WIDTH: f32 = 300.; +/// The web's `THREAD_SIDEBAR_DEFAULT_WIDTH`. +const SIDEBAR_DEFAULT_WIDTH: f32 = 256.; +/// The web's `THREAD_SIDEBAR_MIN_WIDTH`, `13 * 16`. +const SIDEBAR_MIN_WIDTH: f32 = 208.; +/// The web's `THREAD_MAIN_CONTENT_MIN_WIDTH`, `40 * 16`. +const MAIN_CONTENT_MIN_WIDTH: f32 = 640.; +/// The rail's hit area, the web's `w-4`, centered on the sidebar's right edge. +const SIDEBAR_RAIL_WIDTH: f32 = 16.; /// The web's `--workspace-topbar-height` on macOS desktop. const TOPBAR_HEIGHT: f32 = 52.; /// Clears the traffic lights, like `--workspace-controls-left` from `titlebar-area-x`. @@ -65,6 +73,21 @@ const SHEET_SLIDE: f32 = 32.; /// Key context while the overlay is open, so Escape only dismisses it then. pub const OVERLAY_CONTEXT: &str = "SidebarOverlay"; +#[derive(Clone)] +struct SidebarResize; + +impl Render for SidebarResize { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + +/// The web's `resolveInitialThreadSidebarWidth` bounds: the window leaves the content its minimum. +fn clamp_sidebar_width(width: f32, window_width: f32) -> f32 { + let max = SIDEBAR_MIN_WIDTH.max(window_width - MAIN_CONTENT_MIN_WIDTH); + width.clamp(SIDEBAR_MIN_WIDTH, max) +} + fn is_narrow(width: f32) -> bool { width <= SIDEBAR_BREAKPOINT } @@ -120,8 +143,10 @@ pub struct MainView { shelves: [bool; 3], sidebar_open: bool, sidebar_overlay_open: bool, - /// The sidebar's width at the last render, for the cards' branch truncation. - sidebar_width: Cell, + /// The docked sidebar's width as dragged, before fitting the window. + sidebar_width: f32, + /// The docked width at the last render, for the cards' branch truncation. + rendered_sidebar_width: Cell, /// Hover styles cannot change layout, so the send button's hover growth is view state. send_hovered: bool, permissions_open: bool, @@ -190,7 +215,8 @@ impl MainView { shelves: [false; 3], sidebar_open: true, sidebar_overlay_open: false, - sidebar_width: Cell::new(SIDEBAR_WIDTH), + sidebar_width: SIDEBAR_DEFAULT_WIDTH, + rendered_sidebar_width: Cell::new(SIDEBAR_DEFAULT_WIDTH), send_hovered: false, permissions_open: false, selected_thread: None, @@ -353,7 +379,12 @@ impl MainView { } /// One draggable row: the sidebar's `SidebarChrome` on the left, the thread breadcrumb right. - fn top_bar(&self, narrow: bool, cx: &mut Context) -> impl IntoElement { + fn top_bar( + &self, + narrow: bool, + sidebar_width: f32, + cx: &mut Context, + ) -> impl IntoElement { let border = cx.theme().border; let active = self .shell() @@ -369,7 +400,7 @@ impl MainView { .pl_0() .border_b_0() .bg(gpui::transparent_black()) - .child(self.sidebar_chrome(open, SIDEBAR_WIDTH, cx)) + .child(self.sidebar_chrome(open, sidebar_width, cx)) .child( h_flex() .flex_1() @@ -577,8 +608,51 @@ impl MainView { }) } + /// The web's `SidebarRail`: drag to resize, double-click to reset. + fn sidebar_rail(&self, width: f32, cx: &mut Context) -> impl IntoElement { + let line = cx.theme().sidebar_border; + h_flex() + .id("sidebar-rail") + .group("sidebar-rail") + .absolute() + .top(px(TOPBAR_HEIGHT)) + .bottom_0() + .left(px(width - SIDEBAR_RAIL_WIDTH / 2.)) + .w(px(SIDEBAR_RAIL_WIDTH)) + .justify_center() + .occlude() + .cursor_col_resize() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, _, cx| { + if event.click_count == 2 { + this.sidebar_width = SIDEBAR_DEFAULT_WIDTH; + cx.notify(); + } + }), + ) + .on_drag(SidebarResize, |drag, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| drag.clone()) + }) + .on_drag_move( + cx.listener(|this, event: &DragMoveEvent, window, cx| { + let viewport = f32::from(window.viewport_size().width); + this.sidebar_width = + clamp_sidebar_width(f32::from(event.event.position.x), viewport); + cx.notify(); + }), + ) + .child( + div() + .h_full() + .w(px(2.)) + .group_hover("sidebar-rail", move |style| style.bg(line)), + ) + } + fn sidebar(&self, width: f32, window: &Window, cx: &mut Context) -> impl IntoElement { - self.sidebar_width.set(width); + self.rendered_sidebar_width.set(width); let (top, shelves) = self.shell().map_or_else(Default::default, |shell| { ( top_rows(&shell.rows).len(), @@ -641,7 +715,7 @@ impl MainView { project, provider, selected, - self.sidebar_width.get(), + self.rendered_sidebar_width.get(), cx, ) .on_click(cx.listener(move |this, _, _, cx| this.open_thread(Some(id.clone()), cx))) @@ -2430,6 +2504,7 @@ impl Render for MainView { let width = f32::from(window.viewport_size().width); let narrow = is_narrow(width); let docked = self.sidebar_open && !narrow; + let sidebar_width = clamp_sidebar_width(self.sidebar_width, width); let overlay = narrow && self.sidebar_overlay_open; v_flex() .relative() @@ -2447,18 +2522,21 @@ impl Render for MainView { .on_action(cx.listener(|this, _: &GoBack, _, cx| this.go_back(cx))) .on_action(cx.listener(|this, _: &GoForward, _, cx| this.go_forward(cx))) .when(docked, |root| { - root.child(self.sidebar_surface(SIDEBAR_WIDTH, cx)) + root.child(self.sidebar_surface(sidebar_width, cx)) }) - .child(self.top_bar(narrow, cx)) + .child(self.top_bar(narrow, sidebar_width, cx)) .child( h_flex() .flex_1() .min_h_0() .when(docked, |row| { - row.child(self.sidebar(SIDEBAR_WIDTH, window, cx)) + row.child(self.sidebar(sidebar_width, window, cx)) }) .child(self.main_area(cx)), ) + .when(docked, |root| { + root.child(self.sidebar_rail(sidebar_width, cx)) + }) .when(overlay, |root| { root.child(self.sidebar_drawer(overlay_width(width), window, cx)) }) @@ -2477,6 +2555,14 @@ mod tests { assert_eq!(overlay_width(400.), 352.); } + #[test] + fn the_sidebar_width_stays_between_the_minimum_and_what_the_content_leaves() { + assert_eq!(clamp_sidebar_width(100., 1200.), 208.); + assert_eq!(clamp_sidebar_width(300., 1200.), 300.); + assert_eq!(clamp_sidebar_width(900., 1200.), 560.); + assert_eq!(clamp_sidebar_width(300., 800.), 208.); + } + #[test] fn toggling_flips_only_the_state_for_the_viewport() { assert_eq!(toggled(true, true, false), (true, true)); From 8a6918c2149c8c8fb759a65538363c1950bdd475 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 10:56:56 -0500 Subject: [PATCH 19/82] feat(desktop-gpui): sidebar provider icons show the instance chip like the composer --- apps/desktop-gpui/src/providers.rs | 49 +++++++++++++++++++----------- apps/desktop-gpui/src/ui.rs | 34 ++++++--------------- 2 files changed, 42 insertions(+), 41 deletions(-) diff --git a/apps/desktop-gpui/src/providers.rs b/apps/desktop-gpui/src/providers.rs index ab0c329ae4ed..5944bde480c7 100644 --- a/apps/desktop-gpui/src/providers.rs +++ b/apps/desktop-gpui/src/providers.rs @@ -258,11 +258,17 @@ pub struct Badge { pub accent: Option<(u32, u32)>, } +/// What a provider instance's icon shows: the driver's art or initials, plus the instance chip. #[derive(Clone, Debug, PartialEq, Eq)] -pub struct Trigger { +pub struct Mark { pub driver: String, pub display_name: String, pub badge: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Trigger { + pub mark: Mark, pub model: String, pub effort: Option, /// The model's full name, for the tooltip. @@ -309,13 +315,13 @@ impl Catalog { ) } - /// The driver and display name of a provider instance, for the sidebar's provider icon. - pub fn instance(&self, instance_id: &str) -> Option<(&str, String)> { - let provider = self - .providers + /// A provider instance's icon, for the sidebar's thread cards. + pub fn instance(&self, instance_id: &str) -> Option { + let entries = self.entries(None); + let entry = entries .iter() - .find(|provider| provider.instance_id == instance_id)?; - Some((&provider.driver, display_name(provider))) + .find(|entry| entry.provider.instance_id == instance_id)?; + Some(mark(&entries, entry)) } /// The trigger a fresh draft in `project` shows; `None` when no provider is selectable. @@ -339,7 +345,6 @@ impl Catalog { "opencode" | "antigravity" => None, _ => provider.models.first(), }); - let display_name = display_name(provider); let (model, title, effort) = match shown { Some(model) => ( trigger_model_name(model, &provider.driver), @@ -356,9 +361,7 @@ impl Catalog { } }; Some(Trigger { - driver: provider.driver.clone(), - badge: show_badge(&entries, entry).then(|| badge(provider, &display_name)), - display_name, + mark: mark(&entries, entry), model, effort, title, @@ -620,6 +623,15 @@ fn show_badge(entries: &[Entry], entry: &Entry) -> bool { > 1 } +fn mark(entries: &[Entry], entry: &Entry) -> Mark { + let display_name = display_name(entry.provider); + Mark { + driver: entry.provider.driver.clone(), + badge: show_badge(entries, entry).then(|| badge(entry.provider, &display_name)), + display_name, + } +} + fn badge(provider: &Provider, display_name: &str) -> Badge { Badge { initials: initials(display_name), @@ -1044,7 +1056,7 @@ mod tests { ], json!({}), ); - assert_eq!(catalog.trigger(None).unwrap().driver, "claudeAgent"); + assert_eq!(catalog.trigger(None).unwrap().mark.driver, "claudeAgent"); } #[test] @@ -1063,7 +1075,7 @@ mod tests { ], json!({}), ); - assert_eq!(catalog.trigger(None).unwrap().driver, "claudeAgent"); + assert_eq!(catalog.trigger(None).unwrap().mark.driver, "claudeAgent"); } #[test] @@ -1085,7 +1097,7 @@ mod tests { json!({"defaultModelSelection": {"instanceId": "codex", "model": "gpt b"}}), ); let trigger = catalog.trigger(None).unwrap(); - assert_eq!(trigger.driver, "codex"); + assert_eq!(trigger.mark.driver, "codex"); assert_eq!(trigger.title, "GPT B"); } @@ -1104,8 +1116,11 @@ mod tests { providers.clone(), json!({"projectSettingsOverrides": {"p": {"defaultModelSelection": {"instanceId": "claudeAgent", "model": "b"}}}}), ); - assert_eq!(enabled.trigger(Some("p")).unwrap().driver, "claudeAgent"); - assert_eq!(enabled.trigger(None).unwrap().driver, "codex"); + assert_eq!( + enabled.trigger(Some("p")).unwrap().mark.driver, + "claudeAgent" + ); + assert_eq!(enabled.trigger(None).unwrap().mark.driver, "codex"); let disabled = catalog( providers, json!({"projectSettingsOverrides": {"p": { @@ -1113,7 +1128,7 @@ mod tests { "providerInstances": {"claudeAgent": {"enabled": false}} }}}), ); - assert_eq!(disabled.trigger(Some("p")).unwrap().driver, "codex"); + assert_eq!(disabled.trigger(Some("p")).unwrap().mark.driver, "codex"); } #[test] diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 7fba6d1d0f75..b403f3d869a1 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -2,7 +2,7 @@ use crate::assets::{MESSAGE_SQUARE_DASHED, STAGE_DEV, STAGE_DEV_BUTTON, provider use crate::connection::{Action, ActionSender}; use crate::history::History; use crate::projects::{self, EnvironmentProjects, MachineKind, ProjectEntry}; -use crate::providers::{Catalog, Trigger}; +use crate::providers::{Catalog, Mark, Trigger}; use crate::status::{PrBadge, PrState, Section, ShellView, Status, ThreadRow, TopStatus}; use crate::strip::{ self, Branch, Defaults, EnvMode, HOST_SHORTCUT, PERMISSIONS_SHORTCUT, RuntimeMode, @@ -1644,7 +1644,7 @@ fn model_trigger(trigger: &Trigger, cx: &App) -> gpui::Stateful { .hover(move |style| style.bg(foreground.opacity(0.06)).text_color(foreground)) .tooltip(tooltip(trigger.title.clone())) .on_click(|_, _, _| {}) - .child(provider_mark(trigger, cx)) + .child(provider_mark(&trigger.mark, 1., cx)) .child( div() .max_w(px(224.)) @@ -1667,10 +1667,10 @@ fn model_trigger(trigger: &Trigger, cx: &App) -> gpui::Stateful { } /// The web's `ProviderInstanceIcon` at `size-4`: the driver's art, or its initials, with the -/// instance chip at the bottom-right. -fn provider_mark(trigger: &Trigger, cx: &App) -> gpui::Div { +/// instance chip at the bottom-right. `icon_opacity` dims the art only, like `iconClassName`. +fn provider_mark(mark: &Mark, icon_opacity: f32, cx: &App) -> gpui::Div { let theme = cx.theme(); - let art = match provider_icon(&trigger.driver, theme.is_dark()) { + let art = match provider_icon(&mark.driver, theme.is_dark()) { Some(path) => img(path).size_full().into_any_element(), None => div() .size_full() @@ -1679,15 +1679,15 @@ fn provider_mark(trigger: &Trigger, cx: &App) -> gpui::Div { .justify_center() .text_size(px(10.)) .font_weight(FontWeight::SEMIBOLD) - .child(crate::providers::initials(&trigger.display_name)) + .child(crate::providers::initials(&mark.display_name)) .into_any_element(), }; div() .relative() .flex_shrink_0() .size_4() - .child(art) - .children(trigger.badge.as_ref().map(|badge| { + .child(div().size_full().opacity(icon_opacity).child(art)) + .children(mark.badge.as_ref().map(|badge| { let (bg, fg) = match badge.accent { Some((bg, fg)) => (rgb(bg).into(), rgb(fg).into()), None => (theme.background, theme.muted_foreground), @@ -2350,7 +2350,7 @@ fn project_name<'a>(row: &'a ThreadRow, entry: Option<&'a ProjectEntry>) -> &'a fn thread_row( row: &ThreadRow, project: Option<&ProjectEntry>, - provider: Option<(&str, String)>, + provider: Option, selected: bool, sidebar_width: f32, cx: &App, @@ -2386,21 +2386,7 @@ fn thread_row( .child(Icon::new(Icons::Check).size(px(14.))) .child("Settle"), ); - let provider = provider.map(|(driver, display_name)| { - let art = match provider_icon(driver, dark) { - Some(path) => img(path).size_full().into_any_element(), - None => div() - .size_full() - .flex() - .items_center() - .justify_center() - .text_size(px(10.)) - .font_weight(FontWeight::SEMIBOLD) - .child(crate::providers::initials(&display_name)) - .into_any_element(), - }; - div().flex_shrink_0().size_4().opacity(0.6).child(art) - }); + let provider = provider.map(|mark| provider_mark(&mark, 0.6, cx)); div() .id(row.id.clone()) .group(group) From 33f869fbfaa6b5377aed3a010e1ece2fba0c3f7a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 11:08:01 -0500 Subject: [PATCH 20/82] fix(desktop-gpui): sidebar shelves scroll as one list like the web --- apps/desktop-gpui/src/ui.rs | 201 ++++++++++++++++++++++-------------- 1 file changed, 121 insertions(+), 80 deletions(-) diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index b403f3d869a1..221238ef4f82 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -13,8 +13,7 @@ use gpui::{ Anchor, Animation, AnimationExt as _, AnyElement, App, Bounds, Context, DragMoveEvent, Entity, Focusable as _, FollowMode, FontWeight, Hsla, IntoElement, Keystroke, ListAlignment, ListState, MouseButton, MouseDownEvent, ObjectFit, ParentElement, Pixels, Render, RenderOnce, Styled, - UniformListScrollHandle, Window, actions, canvas, div, ease_in_out, img, list, prelude::*, px, - rgb, uniform_list, + Window, actions, canvas, div, ease_in_out, img, list, prelude::*, px, rgb, }; use gpui_component::{ ActiveTheme, Icon, IconName, Selectable, Sizable, TitleBar, @@ -138,7 +137,7 @@ pub struct MainView { search: Entity, project_search: Entity, composer: Entity, - list: UniformListScrollHandle, + settled_shown: usize, /// Working, Snoozed and Settled shelves; the web starts all of them collapsed. shelves: [bool; 3], sidebar_open: bool, @@ -211,7 +210,7 @@ impl MainView { search: cx.new(|cx| InputState::new(window, cx).placeholder("Search")), project_search, composer, - list: UniformListScrollHandle::new(), + settled_shown: SETTLED_TAIL_INITIAL_COUNT, shelves: [false; 3], sidebar_open: true, sidebar_overlay_open: false, @@ -653,12 +652,26 @@ impl MainView { fn sidebar(&self, width: f32, window: &Window, cx: &mut Context) -> impl IntoElement { self.rendered_sidebar_width.set(width); - let (top, shelves) = self.shell().map_or_else(Default::default, |shell| { - ( - top_rows(&shell.rows).len(), - shelves(&shell.rows, self.shelves), - ) - }); + let shelves = self + .shell() + .map_or_else(Vec::new, |shell| shelves(&shell.rows, self.shelves)); + let top = self + .shell() + .map_or_else(Vec::new, |shell| top_rows(&shell.rows)); + let mut list = v_flex() + .id("threads") + .flex_1() + .min_h_0() + .gap_px() + .overflow_y_scroll(); + for index in top { + if let Some(shell) = self.shell() { + list = list.child(self.thread_item(&shell.rows[index], cx).flex_shrink_0()); + } + } + for (position, shelf) in shelves.into_iter().enumerate() { + list = list.child(self.shelf(shelf, position == 0, cx)); + } v_flex() .w(px(width)) .flex_shrink_0() @@ -666,33 +679,7 @@ impl MainView { .gap_2() .p_2() .child(self.sidebar_header(window, cx)) - .child( - uniform_list( - "threads", - top, - cx.processor(|this, range: std::ops::Range, _, cx| { - let Some(shell) = this.shell() else { - return Vec::new(); - }; - let rows = top_rows(&shell.rows); - rows[range] - .iter() - .map(|&index| this.thread_item(&shell.rows[index], cx)) - .collect() - }), - ) - .track_scroll(&self.list) - .flex_1() - .min_h(px(ROW_HEIGHT)), - ) - .child( - v_flex().gap_px().min_h_0().children( - shelves - .into_iter() - .map(|shelf| self.shelf(shelf, cx)) - .collect::>(), - ), - ) + .child(list) .child(self.sidebar_footer(cx)) } @@ -721,8 +708,9 @@ impl MainView { .on_click(cx.listener(move |this, _, _, cx| this.open_thread(Some(id.clone()), cx))) } - /// One collapsible shelf: its header, then its rows while expanded. - fn shelf(&self, shelf: Shelf, cx: &mut Context) -> impl IntoElement + use<> { + /// One collapsible shelf: its header, then its rows while expanded. The first shelf takes + /// the free space above it (`mt-auto`), so shelves sit at the bottom until content overflows. + fn shelf(&self, shelf: Shelf, first: bool, cx: &mut Context) -> AnyElement { let Shelf { section, count, @@ -730,47 +718,60 @@ impl MainView { } = shelf; let index = shelf_index(section); let (muted, hover) = (cx.theme().muted_foreground, cx.theme().list_hover); - let visible = self.shell().map_or(0, |shell| { - shelf_rows( - &shell.rows, - section, - expanded, - self.selected_thread.as_deref(), - ) - .len() - }); + let mut items: Vec = vec![ + shelf_header(section, count, expanded, muted, hover) + .flex_shrink_0() + .when(first, |header| header.mt_auto()) + .on_click(cx.listener(move |this, _, _, cx| { + this.shelves[index] = !this.shelves[index]; + cx.notify(); + })) + .into_any_element(), + ]; + let mut more = 0; + if let Some(shell) = self.shell() { + let selected = self.selected_thread.as_deref(); + let mut rows = shelf_rows(&shell.rows, section, expanded, selected); + if section == Section::Settled && expanded { + let window = settled_window(&rows, &shell.rows, self.settled_shown, selected); + rows = window.rows; + more = window.more; + } + items.extend(rows.into_iter().map(|row| { + self.thread_item(&shell.rows[row], cx) + .flex_shrink_0() + .into_any_element() + })); + } + if more > 0 { + items.push( + h_flex() + .id("settled-show-more") + .h(px(36.)) + .flex_shrink_0() + .mx(px(2.)) + .px_2() + .gap_2() + .rounded_md() + .text_xs() + .text_color(muted) + .cursor_pointer() + .hover(|style| style.bg(hover)) + .child(Icon::new(IconName::ChevronDown).size(px(12.))) + .child(format!("Show {more} more")) + .on_click(cx.listener(|this, _, _, cx| { + this.settled_shown += SETTLED_TAIL_PAGE_COUNT; + cx.notify(); + })) + .into_any_element(), + ); + } v_flex() .gap_px() - .min_h_0() - .child( - shelf_header(section, count, expanded, muted, hover).on_click(cx.listener( - move |this, _, _, cx| { - this.shelves[index] = !this.shelves[index]; - cx.notify(); - }, - )), - ) - .when(visible > 0, |shelf| { - shelf.child( - uniform_list( - ("shelf", index), - visible, - cx.processor(move |this, range: std::ops::Range, _, cx| { - let Some(shell) = this.shell() else { - return Vec::new(); - }; - let selected = this.selected_thread.as_deref(); - let rows = shelf_rows(&shell.rows, section, expanded, selected); - rows[range] - .iter() - .map(|&index| this.thread_item(&shell.rows[index], cx)) - .collect() - }), - ) - .h(px(visible.min(SHELF_VISIBLE_ROWS) as f32 * ROW_HEIGHT)) - .min_h_0(), - ) - }) + .flex_shrink_0() + .when(first, |shelf| shelf.mt_auto()) + .children(items) + .into_any_element() } /// The web's `SidebarChromeFooter`: utility buttons left, the update control right. @@ -2116,8 +2117,9 @@ struct Shelf { expanded: bool, } -/// Rows a shelf shows before it scrolls. -const SHELF_VISIBLE_ROWS: usize = 6; +/// The web's `SETTLED_TAIL_INITIAL_COUNT` and `SETTLED_TAIL_PAGE_COUNT`. +const SETTLED_TAIL_INITIAL_COUNT: usize = 10; +const SETTLED_TAIL_PAGE_COUNT: usize = 25; /// The web's shelf header and footer button size (`h-8`, `size-8`). const SHELF_HEADER_HEIGHT: f32 = 32.; @@ -2152,6 +2154,33 @@ fn shelf_rows( .collect() } +struct SettledWindow { + rows: Vec, + more: usize, +} + +/// The first `shown` settled rows plus the open thread when it falls outside them, and how many +/// the next "Show more" reveals. +fn settled_window( + rows: &[usize], + all: &[ThreadRow], + shown: usize, + selected: Option<&str>, +) -> SettledWindow { + let hidden = rows.len().saturating_sub(shown); + let open = rows + .iter() + .skip(shown) + .copied() + .find(|&index| selected == Some(all[index].id.as_str())); + let mut window = rows[..rows.len().min(shown)].to_vec(); + window.extend(open); + SettledWindow { + rows: window, + more: hidden.min(SETTLED_TAIL_PAGE_COUNT), + } +} + /// The web's shelves, bottom-anchored: Working and Snoozed only when non-empty, Settled always. fn shelves(rows: &[ThreadRow], expanded: [bool; 3]) -> Vec { if rows.is_empty() { @@ -2645,6 +2674,18 @@ mod tests { assert!(shelf_rows(&rows, Section::Settled, false, Some("w")).is_empty()); assert_eq!(shelf_rows(&rows, Section::Settled, false, Some("s")), [3]); assert!(shelves(&[], [true; 3]).is_empty()); + + let settled: Vec = (0..40) + .map(|i| in_section(&format!("s{i}"), Section::Settled)) + .collect(); + let all = shelf_rows(&settled, Section::Settled, true, None); + let first = settled_window(&all, &settled, 10, None); + assert_eq!((first.rows.len(), first.more), (10, 25)); + let open = settled_window(&all, &settled, 10, Some("s30")); + assert_eq!((open.rows.len(), open.rows[10], open.more), (11, 30, 25)); + let last = settled_window(&all, &settled, 35, None); + assert_eq!((last.rows.len(), last.more), (35, 5)); + assert_eq!(settled_window(&all, &settled, 40, None).more, 0); } #[test] From 13a501088527a1bc317547db4e3e91b5e3ed5e3e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 11:14:22 -0500 Subject: [PATCH 21/82] fix(desktop-gpui): the sidebar never resizes narrower than its brand row --- apps/desktop-gpui/src/ui.rs | 165 +++++++++++++++++++++--------------- 1 file changed, 95 insertions(+), 70 deletions(-) diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 221238ef4f82..746eb69b4606 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -81,10 +81,12 @@ impl Render for SidebarResize { } } -/// The web's `resolveInitialThreadSidebarWidth` bounds: the window leaves the content its minimum. -fn clamp_sidebar_width(width: f32, window_width: f32) -> f32 { - let max = SIDEBAR_MIN_WIDTH.max(window_width - MAIN_CONTENT_MIN_WIDTH); - width.clamp(SIDEBAR_MIN_WIDTH, max) +/// The web's sidebar bounds: at least the brand row (`resolveThreadSidebarMinimumWidth`), and +/// the window leaves the content its minimum. +fn clamp_sidebar_width(width: f32, brand_width: f32, window_width: f32) -> f32 { + let min = SIDEBAR_MIN_WIDTH.max(brand_width.ceil()); + let max = min.max(window_width - MAIN_CONTENT_MIN_WIDTH); + width.clamp(min, max) } fn is_narrow(width: f32) -> bool { @@ -146,6 +148,8 @@ pub struct MainView { sidebar_width: f32, /// The docked width at the last render, for the cards' branch truncation. rendered_sidebar_width: Cell, + /// The topbar brand row's natural width at the last paint, the web's `SidebarBrandWidthProbe`. + brand_width: Rc>, /// Hover styles cannot change layout, so the send button's hover growth is view state. send_hovered: bool, permissions_open: bool, @@ -216,6 +220,7 @@ impl MainView { sidebar_overlay_open: false, sidebar_width: SIDEBAR_DEFAULT_WIDTH, rendered_sidebar_width: Cell::new(SIDEBAR_DEFAULT_WIDTH), + brand_width: Rc::new(Cell::new(0.)), send_hovered: false, permissions_open: false, selected_thread: None, @@ -425,72 +430,87 @@ impl MainView { let toggle = Kbd::format(&Keystroke::parse("cmd-b").expect("valid keystroke")); let shortcut = |keys| Kbd::format(&Keystroke::parse(keys).expect("valid keystroke")); let border = cx.theme().border; - h_flex() - .h_full() - .flex_shrink_0() - .when(open, |row| row.w(px(width))) - .when(!open, |row| row.border_b_1().border_color(border)) - .pl(px(TITLEBAR_CONTENT_LEFT)) - .pr_3() - .gap_1() - .child( - nav_button( - "toggle-sidebar", - if open { - IconName::PanelLeftClose - } else { - IconName::PanelLeft - }, - format!("Toggle main sidebar ({toggle})").into(), - true, - backdrop, - cx, - ) - .on_click( - cx.listener(|this, _, window, cx| { - this.toggle_sidebar(narrow_window(window), cx) - }), - ), - ) - // Like the web, the brand and history live in the sidebar and hide with it. - .when(open, |bar| { - bar.child( - h_flex() - .ml_1() - .gap_1() - .text_sm() - .text_color(brand) - .child(div().font_weight(FontWeight::BOLD).child("T3")) - .child( - div() - .font_weight(FontWeight::MEDIUM) - .text_color(brand_muted) - .child("Code"), - ), - ) - .child( - nav_button( - "go-back", - IconName::ArrowLeft, - format!("Go back ({})", shortcut("cmd-[")).into(), - self.history.can_back(), - backdrop, - cx, + let brand_width = self.brand_width.clone(); + let content = + h_flex() + .relative() + .h_full() + .flex_shrink_0() + .gap_1() + .when(open, |row| { + row.child( + canvas( + move |rect, _, _| brand_width.set(f32::from(rect.right()) + 12.), + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), ) - .on_click(cx.listener(|this, _, _, cx| this.go_back(cx))), - ) + }) .child( nav_button( - "go-forward", - IconName::ArrowRight, - format!("Go forward ({})", shortcut("cmd-]")).into(), - self.history.can_forward(), + "toggle-sidebar", + if open { + IconName::PanelLeftClose + } else { + IconName::PanelLeft + }, + format!("Toggle main sidebar ({toggle})").into(), + true, backdrop, cx, ) - .on_click(cx.listener(|this, _, _, cx| this.go_forward(cx))), + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_sidebar(narrow_window(window), cx) + })), ) - }) + // Like the web, the brand and history live in the sidebar and hide with it. + .when(open, |bar| { + bar.child( + h_flex() + .ml_1() + .gap_1() + .text_sm() + .text_color(brand) + .child(div().font_weight(FontWeight::BOLD).child("T3")) + .child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(brand_muted) + .child("Code"), + ), + ) + .child( + nav_button( + "go-back", + IconName::ArrowLeft, + format!("Go back ({})", shortcut("cmd-[")).into(), + self.history.can_back(), + backdrop, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| this.go_back(cx))), + ) + .child( + nav_button( + "go-forward", + IconName::ArrowRight, + format!("Go forward ({})", shortcut("cmd-]")).into(), + self.history.can_forward(), + backdrop, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| this.go_forward(cx))), + ) + }); + h_flex() + .h_full() + .flex_shrink_0() + .when(open, |row| row.w(px(width))) + .when(!open, |row| row.border_b_1().border_color(border)) + .pl(px(TITLEBAR_CONTENT_LEFT)) + .pr_3() + .child(content) } /// Picking a project starts a new thread in it. @@ -637,8 +657,11 @@ impl MainView { .on_drag_move( cx.listener(|this, event: &DragMoveEvent, window, cx| { let viewport = f32::from(window.viewport_size().width); - this.sidebar_width = - clamp_sidebar_width(f32::from(event.event.position.x), viewport); + this.sidebar_width = clamp_sidebar_width( + f32::from(event.event.position.x), + this.brand_width.get(), + viewport, + ); cx.notify(); }), ) @@ -2519,7 +2542,7 @@ impl Render for MainView { let width = f32::from(window.viewport_size().width); let narrow = is_narrow(width); let docked = self.sidebar_open && !narrow; - let sidebar_width = clamp_sidebar_width(self.sidebar_width, width); + let sidebar_width = clamp_sidebar_width(self.sidebar_width, self.brand_width.get(), width); let overlay = narrow && self.sidebar_overlay_open; v_flex() .relative() @@ -2572,10 +2595,12 @@ mod tests { #[test] fn the_sidebar_width_stays_between_the_minimum_and_what_the_content_leaves() { - assert_eq!(clamp_sidebar_width(100., 1200.), 208.); - assert_eq!(clamp_sidebar_width(300., 1200.), 300.); - assert_eq!(clamp_sidebar_width(900., 1200.), 560.); - assert_eq!(clamp_sidebar_width(300., 800.), 208.); + assert_eq!(clamp_sidebar_width(100., 0., 1200.), 208.); + assert_eq!(clamp_sidebar_width(300., 0., 1200.), 300.); + assert_eq!(clamp_sidebar_width(900., 0., 1200.), 560.); + assert_eq!(clamp_sidebar_width(300., 0., 800.), 208.); + assert_eq!(clamp_sidebar_width(200., 251.4, 1200.), 252.); + assert_eq!(clamp_sidebar_width(300., 251.4, 800.), 252.); } #[test] From c4235fb01f564b9bc6343cf9f8fc885258f1409f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 11:28:41 -0500 Subject: [PATCH 22/82] feat(desktop-gpui): transcript minimap with turn previews like the web --- apps/desktop-gpui/Cargo.lock | 1 + apps/desktop-gpui/Cargo.toml | 1 + apps/desktop-gpui/src/main.rs | 1 + apps/desktop-gpui/src/minimap.rs | 410 +++++++++++++++++++++++++++++++ apps/desktop-gpui/src/ui.rs | 211 +++++++++++++++- 5 files changed, 617 insertions(+), 7 deletions(-) create mode 100644 apps/desktop-gpui/src/minimap.rs diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index 23c2051c8ed5..1fd614b4f8eb 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -6404,6 +6404,7 @@ dependencies = [ "hex", "hmac 0.13.0", "libc", + "markdown", "p256", "prettyplease", "reqwest", diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index a28db8ac1b16..89688ba47d58 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -18,6 +18,7 @@ gpui_platform = { package = "gpui-pre-platform", version = "=0.3.11", features = hex = "0.4.3" hmac = "0.13.0" libc = "0.2.191" +markdown = "1.0.0" p256 = { version = "0.14", default-features = false, features = ["ecdsa", "pkcs8", "std"] } reqwest = { version = "0.13.5", default-features = false, features = ["form", "json", "rustls"] } serde = { version = "1.0.229", features = ["derive"] } diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 126256d7d0f5..165ef7c22b53 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -6,6 +6,7 @@ mod connection; mod electron_dpop; mod environments; mod history; +mod minimap; mod projects; mod providers; mod rpc; diff --git a/apps/desktop-gpui/src/minimap.rs b/apps/desktop-gpui/src/minimap.rs new file mode 100644 index 000000000000..f9f87feffaf5 --- /dev/null +++ b/apps/desktop-gpui/src/minimap.rs @@ -0,0 +1,410 @@ +//! The transcript minimap's data, mirroring `timelineMinimapItems.ts` and the geometry helpers in +//! `MessagesTimeline.logic.ts`: one tick per user message, previewed on hover. + +use crate::thread::{Entry, Kind}; +use markdown::{ParseOptions, mdast::Node}; +use std::sync::Arc; + +pub const MIN_ITEMS: usize = 2; +const ITEM_SPACING: f32 = 8.; +/// The web's `calc(100vh - 18rem)`. +const MAX_HEIGHT_INSET: f32 = 288.; +const PERSISTENT_GUTTER: f32 = 48.; +const HIT_STRIP_LEFT: f32 = 12.; +const HIT_STRIP_MAX_WIDTH: f32 = 40.; +/// The tooltip shows three lines; bounds parsing work while long responses stream. +const PREVIEW_MARKDOWN_CHARS: usize = 1_500; +/// The transcript column the web measures the side gutter against. +pub const CONTENT_WIDTH: f32 = 720.; +const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT: &str = "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Item { + /// The index of the user message in the transcript rows. + pub row: usize, + pub user: Arc, + pub assistant: Option>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Preview { + pub user: Option, + pub assistant: Option, +} + +fn is_attachment_only_bootstrap_prompt(text: &str) -> bool { + let rest = text + .get(..10) + .filter(|head| head.eq_ignore_ascii_case("ultrathink")) + .and_then(|_| text[10..].strip_prefix(':')) + .map_or(text, str::trim_start); + rest == ATTACHMENT_ONLY_BOOTSTRAP_PROMPT +} + +/// One item per user message, with the turn's final assistant text. +pub fn items(entries: &[Entry]) -> Vec { + let mut items: Vec = Vec::new(); + let mut in_turn = false; + for (row, entry) in entries.iter().enumerate() { + match entry.kind { + Kind::User => { + in_turn = true; + let user = if is_attachment_only_bootstrap_prompt(&entry.text) { + Arc::from("") + } else { + entry.text.clone() + }; + items.push(Item { + row, + user, + assistant: None, + }); + } + Kind::Assistant if in_turn => { + if let Some(item) = items.last_mut() { + item.assistant = Some(entry.text.clone()); + } + } + _ => {} + } + } + items +} + +fn compact(text: &str) -> Option { + let compact = text.split_whitespace().collect::>().join(" "); + (!compact.is_empty()).then_some(compact) +} + +pub fn preview(item: &Item) -> Preview { + Preview { + user: compact(&item.user), + assistant: item.assistant.as_deref().and_then(|text| { + let head: String = text.chars().take(PREVIEW_MARKDOWN_CHARS).collect(); + compact(&markdown_text(&head)) + }), + } +} + +fn markdown_text(markdown: &str) -> String { + match markdown::to_mdast(markdown, &ParseOptions::gfm()) { + Ok(node) => node_text(&node), + Err(_) => markdown.to_owned(), + } +} + +fn node_text(node: &Node) -> String { + match node { + Node::Text(n) => return n.value.clone(), + Node::InlineCode(n) => return n.value.clone(), + Node::Code(n) => return n.value.clone(), + Node::InlineMath(n) => return n.value.clone(), + Node::Math(n) => return n.value.clone(), + Node::Html(n) => return html_text(&n.value), + Node::Break(_) => return " ".to_owned(), + Node::Image(n) => return n.alt.clone(), + Node::ImageReference(n) => return n.alt.clone(), + _ => {} + } + let Some(children) = node.children() else { + return String::new(); + }; + let inline = matches!( + node, + Node::Paragraph(_) + | Node::Heading(_) + | Node::TableCell(_) + | Node::Emphasis(_) + | Node::Strong(_) + | Node::Delete(_) + | Node::Link(_) + | Node::LinkReference(_) + ); + let parts: Vec = children.iter().map(node_text).collect(); + let text = parts.join(if inline { "" } else { " " }); + // A thread autolink's only text is its URL. + if matches!(node, Node::Link(_)) && is_thread_link(&text) { + return "a thread".to_owned(); + } + text +} + +fn is_thread_link(text: &str) -> bool { + ["t3-thread://v1/", "t3-thread://v2/"].iter().any(|prefix| { + text.strip_prefix(prefix) + .is_some_and(|rest| !rest.is_empty()) + }) +} + +const BLOCK_TAGS: [&str; 34] = [ + "address", + "article", + "aside", + "blockquote", + "br", + "dd", + "details", + "div", + "dl", + "dt", + "figcaption", + "figure", + "footer", + "h1", + "h2", + "h3", + "h4", + "h5", + "h6", + "header", + "hr", + "li", + "main", + "nav", + "ol", + "p", + "pre", + "section", + "summary", + "table", + "tbody", + "td", + "th", + "thead", +]; + +/// Drops comments and tags; a block-level tag leaves a space behind. +fn html_text(html: &str) -> String { + let mut out = String::new(); + let mut rest = html; + while let Some(at) = rest.find('<') { + out.push_str(&rest[..at]); + rest = &rest[at..]; + if let Some(comment) = rest.strip_prefix("").map_or("", |end| &comment[end + 3..]); + } else if let Some((len, name)) = tag_at(rest) { + if BLOCK_TAGS.contains(&name.to_ascii_lowercase().as_str()) + || ["tr", "ul"].contains(&name.to_ascii_lowercase().as_str()) + { + out.push(' '); + } + rest = &rest[len..]; + } else { + out.push('<'); + rest = &rest[1..]; + } + } + out.push_str(rest); + out +} + +/// The length and name of the tag at the start of `text`, like ``. +fn tag_at(text: &str) -> Option<(usize, &str)> { + let body = text.strip_prefix('<')?; + let body = body.strip_prefix('/').unwrap_or(body); + let start = text.len() - body.len(); + if !body.starts_with(|c: char| c.is_ascii_alphabetic()) { + return None; + } + let name_len = body + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-')) + .unwrap_or(body.len()); + let mut chars = body[name_len..].char_indices(); + while let Some((i, c)) = chars.next() { + match c { + '>' => return Some((start + name_len + i + 1, &body[..name_len])), + '<' => return None, + '"' | '\'' => { + chars.find(|&(_, q)| q == c)?; + } + _ => {} + } + } + None +} + +/// Where item `index` sits along the rail, 0 to 100. +pub fn top_percent(index: usize, count: usize) -> f32 { + if count <= 1 { + return 0.; + } + index.min(count - 1) as f32 / (count - 1) as f32 * 100. +} + +pub fn index_from_pointer( + count: usize, + rail_top: f32, + rail_height: f32, + pointer_y: f32, +) -> Option { + if count == 0 || rail_height <= 0. { + return None; + } + if count == 1 { + return Some(0); + } + let progress = ((pointer_y - rail_top) / rail_height).clamp(0., 1.); + Some(((progress * (count - 1) as f32).round() as usize).min(count - 1)) +} + +pub fn rail_height(count: usize, viewport_height: f32) -> f32 { + let natural = (count.saturating_sub(1) as f32 * ITEM_SPACING).max(1.); + natural.min((viewport_height - MAX_HEIGHT_INSET).max(0.)) +} + +fn side_gutter(viewport_width: f32) -> f32 { + if viewport_width <= 0. { + return 0.; + } + (viewport_width - viewport_width.min(CONTENT_WIDTH)) / 2. +} + +pub fn has_persistent_gutter(viewport_width: f32) -> bool { + side_gutter(viewport_width) >= PERSISTENT_GUTTER +} + +pub fn hit_strip_width(viewport_width: f32) -> f32 { + (side_gutter(viewport_width).floor() - HIT_STRIP_LEFT).clamp(0., HIT_STRIP_MAX_WIDTH) +} + +pub const HIT_STRIP_LEFT_PX: f32 = HIT_STRIP_LEFT; + +/// The strip's length factor while hovering: a fisheye around the hovered tick. +pub fn strip_scale(distance: Option) -> f32 { + match distance { + Some(0) => 1., + Some(1) => 0.667, + Some(2) => 0.417, + _ => 0.333, + } +} + +/// How far the card shifts along its tick, as a fraction of its height: the ends stay in the rail. +pub fn card_shift(index: usize, count: usize) -> f32 { + if index == 0 { + 0. + } else if index + 1 == count { + -1. + } else { + -0.5 + } +} + +/// The items on screen, given the first and last visible rows. +pub fn in_view(row: usize, visible: Option<(usize, usize)>) -> bool { + visible.is_some_and(|(start, end)| (start..=end).contains(&row)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn entry(kind: Kind, text: &str) -> Entry { + Entry { + id: text.to_owned(), + kind, + text: text.into(), + } + } + + fn assistant_preview(text: &str) -> Option { + preview(&Item { + row: 0, + user: "".into(), + assistant: Some(text.into()), + }) + .assistant + } + + #[test] + fn bootstrap_text_is_omitted_from_the_label_and_card() { + for prefix in ["", "Ultrathink:\n"] { + let text = format!("{prefix}{ATTACHMENT_ONLY_BOOTSTRAP_PROMPT}"); + let found = items(&[entry(Kind::User, &text)]); + assert_eq!(found.len(), 1); + assert_eq!(&*found[0].user, ""); + assert_eq!(found[0].assistant, None); + assert_eq!(preview(&found[0]).user, None); + } + } + + #[test] + fn items_pair_each_user_message_with_the_turns_final_assistant_text() { + let found = items(&[ + entry(Kind::Assistant, "orphan"), + entry(Kind::User, "one"), + entry(Kind::Assistant, "draft"), + entry(Kind::Work, "$ ls"), + entry(Kind::Assistant, "final"), + entry(Kind::User, "two"), + ]); + let rows: Vec<_> = found + .iter() + .map(|item| (item.row, &*item.user, item.assistant.as_deref())) + .collect(); + assert_eq!(rows, vec![(1, "one", Some("final")), (5, "two", None)]); + } + + #[test] + fn previews_compact_whitespace() { + let item = Item { + row: 0, + user: " fix\n\nthe bug ".into(), + assistant: None, + }; + assert_eq!(preview(&item).user.as_deref(), Some("fix the bug")); + } + + #[test] + fn a_thread_autolink_previews_as_prose_not_its_url() { + assert_eq!( + assistant_preview( + "Started and [Fix CI](t3-thread://v2/env-1/t2)." + ) + .as_deref(), + Some("Started a thread and Fix CI.") + ); + } + + #[test] + fn markdown_previews_as_plain_text() { + assert_eq!( + assistant_preview("# Title\n\nSome **bold** and `code`.\n\n- one\n- two").as_deref(), + Some("Title Some bold and code. one two") + ); + assert_eq!( + assistant_preview("a
b c").as_deref(), + Some("a b c") + ); + } + + #[test] + fn only_the_head_of_a_long_reply_is_parsed() { + let long = "x".repeat(PREVIEW_MARKDOWN_CHARS + 500); + assert_eq!( + assistant_preview(&long).map(|text| text.len()), + Some(PREVIEW_MARKDOWN_CHARS) + ); + } + + #[test] + fn rail_geometry_follows_the_web() { + assert_eq!(top_percent(0, 5), 0.); + assert_eq!(top_percent(2, 5), 50.); + assert_eq!(top_percent(9, 5), 100.); + assert_eq!(index_from_pointer(5, 100., 32., 100. + 16.), Some(2)); + assert_eq!(index_from_pointer(5, 100., 32., 500.), Some(4)); + assert_eq!(index_from_pointer(0, 100., 32., 110.), None); + assert_eq!(rail_height(5, 2000.), 32.); + assert_eq!(rail_height(1000, 600.), 312.); + assert!(has_persistent_gutter(900.)); + assert!(!has_persistent_gutter(800.)); + assert_eq!(hit_strip_width(900.), 40.); + assert_eq!(hit_strip_width(750.), 3.); + assert_eq!(hit_strip_width(720.), 0.); + assert_eq!(card_shift(0, 4), 0.); + assert_eq!(card_shift(3, 4), -1.); + assert_eq!(card_shift(1, 4), -0.5); + assert!(in_view(3, Some((2, 4))) && !in_view(5, Some((2, 4))) && !in_view(3, None)); + } +} diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 746eb69b4606..409912fc137b 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -1,6 +1,7 @@ use crate::assets::{MESSAGE_SQUARE_DASHED, STAGE_DEV, STAGE_DEV_BUTTON, provider_icon}; use crate::connection::{Action, ActionSender}; use crate::history::History; +use crate::minimap; use crate::projects::{self, EnvironmentProjects, MachineKind, ProjectEntry}; use crate::providers::{Catalog, Mark, Trigger}; use crate::status::{PrBadge, PrState, Section, ShellView, Status, ThreadRow, TopStatus}; @@ -11,9 +12,10 @@ use crate::strip::{ use crate::thread::{Entry, Kind}; use gpui::{ Anchor, Animation, AnimationExt as _, AnyElement, App, Bounds, Context, DragMoveEvent, Entity, - Focusable as _, FollowMode, FontWeight, Hsla, IntoElement, Keystroke, ListAlignment, ListState, - MouseButton, MouseDownEvent, ObjectFit, ParentElement, Pixels, Render, RenderOnce, Styled, - Window, actions, canvas, div, ease_in_out, img, list, prelude::*, px, rgb, + Focusable as _, FollowMode, FontWeight, Hsla, IntoElement, Keystroke, ListAlignment, + ListOffset, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, ObjectFit, ParentElement, + Pixels, Render, RenderOnce, Styled, Window, actions, canvas, div, ease_in_out, img, list, + prelude::*, px, rgb, }; use gpui_component::{ ActiveTheme, Icon, IconName, Selectable, Sizable, TitleBar, @@ -156,6 +158,9 @@ pub struct MainView { selected_thread: Option, transcript: Option, transcript_list: ListState, + minimap_items: Vec, + /// The hovered tick and its card text, parsed once when the hover lands on it. + minimap_hover: Option<(usize, minimap::Preview)>, /// Local view state until the draft's mode is sent to the server. runtime_mode: Option, permissions: Entity, @@ -227,6 +232,8 @@ impl MainView { transcript: None, // Overdraw keeps a screenful of rows measured beyond each edge. transcript_list: ListState::new(0, ListAlignment::Top, px(600.)), + minimap_items: Vec::new(), + minimap_hover: None, runtime_mode: None, permissions, selected_project: None, @@ -305,6 +312,10 @@ impl MainView { self.transcript_list.set_follow_mode(FollowMode::Tail); } } + self.minimap_items = result + .as_ref() + .map_or_else(|_| Vec::new(), |entries| minimap::items(entries)); + self.minimap_hover = None; self.transcript = Some(Transcript { id, result }); } @@ -892,7 +903,7 @@ impl MainView { ) } - fn main_area(&self, cx: &mut Context) -> AnyElement { + fn main_area(&self, viewport_height: f32, cx: &mut Context) -> AnyElement { let Some(shell) = self.shell() else { return centered(cx) .text_xl() @@ -907,7 +918,7 @@ impl MainView { .flex_1() .min_w_0() .h_full() - .child(self.transcript_view(cx)) + .child(self.transcript_view(viewport_height, cx)) .child( v_flex() .w_full() @@ -945,7 +956,7 @@ impl MainView { } /// The open thread's rows, or why there are none yet. - fn transcript_view(&self, cx: &mut Context) -> AnyElement { + fn transcript_view(&self, viewport_height: f32, cx: &mut Context) -> AnyElement { let note = |text: String| { centered(cx) .text_sm() @@ -958,6 +969,7 @@ impl MainView { Some(Err(message)) => note(format!("Could not load this thread: {message}")), Some(Ok(entries)) if entries.is_empty() => note("No messages yet".to_owned()), Some(Ok(_)) => div() + .relative() .flex_1() .min_h_0() .w_full() @@ -968,10 +980,195 @@ impl MainView { ) .size_full(), ) + .children(self.transcript_minimap(viewport_height, cx)) .into_any_element(), } } + /// The rows on screen, from the list's last layout. + fn visible_rows(&self) -> Option<(usize, usize)> { + let viewport = self.transcript_list.viewport_bounds(); + if viewport.size.height <= px(0.) { + return None; + } + let start = self.transcript_list.logical_scroll_top().item_ix; + let mut end = start; + for ix in start..self.transcript_list.item_count() { + match self.transcript_list.bounds_for_item(ix) { + Some(bounds) if bounds.top() < viewport.bottom() => end = ix, + _ => break, + } + } + Some((start, end)) + } + + /// The web's `TimelineMinimap`: a tick per user turn at the left edge, brighter while the turn + /// is on screen, with a card of the turn on hover and a click that scrolls to it. + fn transcript_minimap( + &self, + viewport_height: f32, + cx: &mut Context, + ) -> Option { + let items = &self.minimap_items; + let count = items.len(); + let viewport = self.transcript_list.viewport_bounds(); + let (width, height) = ( + f32::from(viewport.size.width), + f32::from(viewport.size.height), + ); + let hit_width = minimap::hit_strip_width(width); + if count < minimap::MIN_ITEMS || hit_width <= 0. || height <= 0. { + return None; + } + let theme = cx.theme(); + let (foreground, muted, border, popover) = ( + theme.foreground, + theme.muted_foreground, + theme.border, + theme.popover, + ); + let rail_height = minimap::rail_height(count, viewport_height); + let rail_top = (height - rail_height) / 2.; + let window_top = f32::from(viewport.top()) + rail_top; + let hover = self + .minimap_hover + .as_ref() + .filter(|(index, _)| *index < count); + let hover_index = hover.map(|(index, _)| *index); + let visible = self.visible_rows(); + let persistent = minimap::has_persistent_gutter(width); + let strips = items.iter().enumerate().map(|(index, item)| { + let distance = hover_index.map(|active| index.abs_diff(active)); + let top = minimap::top_percent(index, count) / 100. * rail_height; + div() + .absolute() + .left_0() + .top(px(top - 1.)) + .h(px(2.)) + .w(px(24. * minimap::strip_scale(distance))) + .rounded_full() + .bg(muted.opacity(if distance == Some(0) { 0.6 } else { 0.25 })) + .when(minimap::in_view(item.row, visible), |strip| { + strip.child( + div() + .size_full() + .rounded_full() + .bg(foreground.opacity(0.55)), + ) + }) + }); + let card = hover.map(|(index, preview)| { + let top = minimap::top_percent(*index, count) / 100. * rail_height; + // The web shifts the card by a share of its own height, which GPUI cannot do before + // layout, so the card sits in a zero-height row that is centered, or pinned, on the tick. + let shift = minimap::card_shift(*index, count); + div() + .absolute() + .left(px(32.)) + .top(px(top)) + .w(px(320.)) + .h_0() + .flex() + .flex_col() + .justify_center() + .when(shift == 0., |row| row.justify_start()) + .when(shift == -1., |row| row.justify_end()) + .child( + div() + .id("minimap-card") + .flex_shrink_0() + .p_3() + .rounded(px(12.)) + .border_1() + .border_color(border) + .bg(popover) + .shadow_xl() + .on_mouse_move(|_, _, cx| cx.stop_propagation()) + .on_click(|_, _, cx| cx.stop_propagation()) + .child( + div() + .truncate() + .text_sm() + .line_height(px(20.)) + .font_weight(FontWeight::MEDIUM) + .child( + preview + .user + .clone() + .unwrap_or_else(|| "User message".to_owned()), + ), + ) + .children(preview.assistant.clone().map(|text| { + div() + .mt_1() + .line_clamp(3) + .text_sm() + .line_height(px(20.)) + .text_color(muted) + .child(text) + })), + ) + }); + Some( + div() + .id("transcript-minimap") + .absolute() + .left(px(minimap::HIT_STRIP_LEFT_PX)) + .top(px(rail_top)) + .h(px(rail_height)) + .w(px(if hover.is_some() { 352. } else { hit_width })) + .cursor_pointer() + .when(!persistent && hover.is_none(), |rail| rail.opacity(0.)) + .on_mouse_move(cx.listener(move |this, event: &MouseMoveEvent, _, cx| { + let y = f32::from(event.position.y); + let index = minimap::index_from_pointer( + this.minimap_items.len(), + window_top, + rail_height, + y, + ); + if index == this.minimap_hover.as_ref().map(|(index, _)| *index) { + return; + } + this.minimap_hover = index.and_then(|index| { + let item = this.minimap_items.get(index)?; + Some((index, minimap::preview(item))) + }); + cx.notify(); + })) + .on_hover(cx.listener(|this, hovered: &bool, _, cx| { + if !hovered && this.minimap_hover.take().is_some() { + cx.notify(); + } + })) + .on_click(cx.listener(|this, _, _, cx| { + let row = this + .minimap_hover + .as_ref() + .and_then(|(index, _)| this.minimap_items.get(*index).map(|item| item.row)); + if let Some(item_ix) = row { + this.transcript_list.scroll_to(ListOffset { + item_ix, + offset_in_item: px(0.), + }); + cx.notify(); + } + })) + .child( + div() + .absolute() + .top_0() + .left(px(12.)) + .h_full() + .w(px(1.)) + .bg(border.opacity(0.15)), + ) + .children(strips) + .children(card) + .into_any_element(), + ) + } + /// One row in the web's 720px column: the user's words in a bubble on the right, the /// assistant's plain, tool work as a muted line. fn transcript_item(&self, ix: usize, cx: &App) -> AnyElement { @@ -2570,7 +2767,7 @@ impl Render for MainView { .when(docked, |row| { row.child(self.sidebar(sidebar_width, window, cx)) }) - .child(self.main_area(cx)), + .child(self.main_area(f32::from(window.viewport_size().height), cx)), ) .when(docked, |root| { root.child(self.sidebar_rail(sidebar_width, cx)) From 19b54e48ffeb0efbe90b7310d0377bb3145d31ac Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 11:39:00 -0500 Subject: [PATCH 23/82] feat(desktop-gpui): open threads float a glass composer over the transcript without the context strip --- apps/desktop-gpui/src/ui.rs | 86 +++++++++++++++++++++++++++++-------- 1 file changed, 68 insertions(+), 18 deletions(-) diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 409912fc137b..fcfb570ff8f6 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -152,6 +152,9 @@ pub struct MainView { rendered_sidebar_width: Cell, /// The topbar brand row's natural width at the last paint, the web's `SidebarBrandWidthProbe`. brand_width: Rc>, + /// The floating composer's height at the last paint; the transcript's last row reserves it. + composer_height: Rc>, + reserved_composer_height: Cell, /// Hover styles cannot change layout, so the send button's hover growth is view state. send_hovered: bool, permissions_open: bool, @@ -226,6 +229,8 @@ impl MainView { sidebar_width: SIDEBAR_DEFAULT_WIDTH, rendered_sidebar_width: Cell::new(SIDEBAR_DEFAULT_WIDTH), brand_width: Rc::new(Cell::new(0.)), + composer_height: Rc::new(Cell::new(0.)), + reserved_composer_height: Cell::new(0.), send_hovered: false, permissions_open: false, selected_thread: None, @@ -305,6 +310,12 @@ impl MainView { _ => 0, }; let (old, new) = (count(&previous.result), count(&result)); + // The old last row loses the composer reserve when rows arrive after it. + let same = if same == old && new > old { + same.saturating_sub(1) + } else { + same + }; self.transcript_list.splice(same..old, new - same); } None => { @@ -914,19 +925,38 @@ impl MainView { let scratch = active.is_some_and(|entry| entry.scratch); let project = active.map_or_else(|| "your project".to_owned(), |entry| entry.title.clone()); if self.selected_thread.is_some() { + // The web's `persistComposerContextStrip` defaults off, so open threads show no strip, + // and the composer floats over the transcript, which scrolls beneath it. + let measured = self.composer_height.clone(); return v_flex() + .relative() .flex_1() .min_w_0() .h_full() .child(self.transcript_view(viewport_height, cx)) .child( v_flex() - .w_full() + .absolute() + .bottom_0() + .left_0() + .right_0() .items_center() .px_6() .pb_4() - .child(self.composer_box(cx)) - .children(active.and_then(|entry| self.context_strip(entry, cx))), + .child( + canvas( + move |rect, window, _| { + let height = f32::from(rect.size.height); + if measured.replace(height) != height { + window.refresh(); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ) + .child(self.composer_box(cx)), ) .into_any_element(); } @@ -968,23 +998,34 @@ impl MainView { None => note("Loading...".to_owned()), Some(Err(message)) => note(format!("Could not load this thread: {message}")), Some(Ok(entries)) if entries.is_empty() => note("No messages yet".to_owned()), - Some(Ok(_)) => div() - .relative() - .flex_1() - .min_h_0() - .w_full() - .child( - list( - self.transcript_list.clone(), - cx.processor(|this, ix, _, cx| this.transcript_item(ix, cx)), - ) - .size_full(), - ) - .children(self.transcript_minimap(viewport_height, cx)) - .into_any_element(), + Some(Ok(entries)) => { + let reserve = self.composer_height.get(); + if self.reserved_composer_height.replace(reserve) != reserve { + let last = entries.len() - 1; + self.transcript_list.remeasure_items(last..last + 1); + } + self.transcript_list_view(viewport_height, cx) + } } } + fn transcript_list_view(&self, viewport_height: f32, cx: &mut Context) -> AnyElement { + div() + .relative() + .flex_1() + .min_h_0() + .w_full() + .child( + list( + self.transcript_list.clone(), + cx.processor(|this, ix, _, cx| this.transcript_item(ix, cx)), + ) + .size_full(), + ) + .children(self.transcript_minimap(viewport_height, cx)) + .into_any_element() + } + /// The rows on screen, from the list's last layout. fn visible_rows(&self) -> Option<(usize, usize)> { let viewport = self.transcript_list.viewport_bounds(); @@ -1181,6 +1222,11 @@ impl MainView { return div().into_any_element(); }; let theme = cx.theme(); + let last = self + .transcript + .as_ref() + .and_then(|t| t.result.as_ref().ok()) + .is_some_and(|entries| ix + 1 == entries.len()); let text: gpui::SharedString = entry.text.to_string().into(); let body = match entry.kind { Kind::User => h_flex().justify_end().child( @@ -1209,6 +1255,9 @@ impl MainView { .justify_center() .px_6() .py_2() + .when(last, |row| { + row.pb(px(8. + self.reserved_composer_height.get())) + }) .child(div().w_full().max_w(px(720.)).child(body)) .into_any_element() } @@ -1591,7 +1640,8 @@ impl MainView { .border_1() // The web's dark `--chat-composer-outline`. .border_color(gpui::white().opacity(0.05)) - .bg(cx.theme().secondary) + // The web's glass surface at `--glass-opacity`; GPUI has no backdrop blur. + .bg(cx.theme().secondary.opacity(0.8)) .child( // The web's `px-4 pt-4 pb-2` body; the negative margin cancels the textarea's own padding. div().px_4().pt_4().pb_2().child( From 8df8a590770519d5dfc686d59edb50eddaccb8ed Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 12:05:16 -0500 Subject: [PATCH 24/82] fix(desktop-gpui): large shell snapshots no longer drop the connection The server sent a 20 MB message and tungstenite rejected it against its 16 MiB frame default, so the client looped on reconnect. Browsers cap neither frames nor messages, so the client now lifts both limits. --- apps/desktop-gpui/src/connection.rs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 67faeb9bb90c..4d1595ddf02c 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -20,7 +20,7 @@ use futures_util::{SinkExt, StreamExt}; use std::collections::{HashMap, HashSet}; use std::time::Duration; use tokio::time::{Instant, MissedTickBehavior, interval, sleep, sleep_until}; -use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::tungstenite::{Message, protocol::WebSocketConfig}; const PING_INTERVAL: Duration = Duration::from_secs(5); // Pings go out every 5 s and a busy server stops answering them, so allow 15 s @@ -235,9 +235,14 @@ async fn connect( )?; url.query_pairs_mut().append_pair("dpop", &proof); } - let (socket, _) = tokio_tungstenite::connect_async(url.as_str()) - .await - .map_err(|error| error.to_string())?; + // Browsers cap neither frames nor messages, and shell snapshots outgrow tungstenite's 16 MiB default. + let config = WebSocketConfig::default() + .max_frame_size(None) + .max_message_size(None); + let (socket, _) = + tokio_tungstenite::connect_async_with_config(url.as_str(), Some(config), false) + .await + .map_err(|error| error.to_string())?; Ok(socket) } From 4f308464b11d9439cfe8778fc34debe6fdd42fc7 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 12:06:33 -0500 Subject: [PATCH 25/82] feat(desktop-gpui): hovering a sidebar thread shows the web's thread card --- apps/desktop-gpui/src/assets.rs | 4 +- apps/desktop-gpui/src/main.rs | 1 + apps/desktop-gpui/src/providers.rs | 12 + apps/desktop-gpui/src/shell.rs | 28 ++- apps/desktop-gpui/src/status.rs | 13 ++ apps/desktop-gpui/src/thread_card.rs | 331 +++++++++++++++++++++++++++ apps/desktop-gpui/src/ui.rs | 277 +++++++++++++++++++++- 7 files changed, 654 insertions(+), 12 deletions(-) create mode 100644 apps/desktop-gpui/src/thread_card.rs diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 89a784a71f4e..152514ce65c6 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -39,7 +39,8 @@ gpui_kit_assets::icon_assets!( GitPullRequestDraft, GitPullRequestClosed, GitMerge, - Layers + Layers, + ArrowRightLeft ] ); @@ -217,6 +218,7 @@ mod tests { IconName::GitPullRequestClosed, IconName::GitMerge, IconName::Layers, + IconName::ArrowRightLeft, IconName::Computer, ] { let loaded = AppAssets.load(&icon.path()).ok().flatten(); diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 165ef7c22b53..ccaea439089e 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -15,6 +15,7 @@ mod shell; mod status; mod strip; mod thread; +mod thread_card; mod ui; mod wire; diff --git a/apps/desktop-gpui/src/providers.rs b/apps/desktop-gpui/src/providers.rs index 5944bde480c7..6e66cc34e3f1 100644 --- a/apps/desktop-gpui/src/providers.rs +++ b/apps/desktop-gpui/src/providers.rs @@ -324,6 +324,18 @@ impl Catalog { Some(mark(&entries, entry)) } + /// The model as the sidebar card names it: the instance's short name for the slug, else the slug. + pub fn model_label(&self, instance_id: &str, slug: &str) -> String { + self.providers + .iter() + .find(|provider| provider.instance_id == instance_id) + .and_then(|provider| { + let model = provider.models.iter().find(|model| model.slug == slug)?; + Some(trigger_model_name(model, &provider.driver)) + }) + .unwrap_or_else(|| slug.to_owned()) + } + /// The trigger a fresh draft in `project` shows; `None` when no provider is selectable. pub fn trigger(&self, project: Option<&str>) -> Option { let entries = self.entries(project); diff --git a/apps/desktop-gpui/src/shell.rs b/apps/desktop-gpui/src/shell.rs index 99359f9b381b..211323404d20 100644 --- a/apps/desktop-gpui/src/shell.rs +++ b/apps/desktop-gpui/src/shell.rs @@ -2,13 +2,14 @@ //! `packages/client-runtime/src/state/shellReducer.ts` for what this client shows. use crate::projects; -use crate::status::{PrBadge, PrState, Section, ShellView, ThreadRow, TopStatus}; +use crate::status::{LastError, PrBadge, PrState, Section, ShellView, ThreadRow, TopStatus}; +use crate::thread_card; use crate::wire::generated::{ LineageRelationshipToParent as LineageRelationship, PullRequestsItem, PullRequestsItemSource as LinkSource, SnapshotState as PrSnapshotState, ThreadsEntryActivityRunStatus as Activity, ThreadsEntryInteractionMode as InteractionMode, - ThreadsEntryPendingRuntimeRequestKind as RequestKind, ThreadsEntrySettledOverride as Override, - ThreadsEntryStatus as RunStatus, + ThreadsEntryLastErrorClass, ThreadsEntryPendingRuntimeRequestKind as RequestKind, + ThreadsEntrySettledOverride as Override, ThreadsEntryStatus as RunStatus, }; use crate::wire::generated::{ OrchestrationSubscribeShellSuccess as Item, @@ -21,7 +22,7 @@ use std::collections::HashMap; use std::time::{SystemTime, UNIX_EPOCH}; /// Epoch milliseconds of an RFC 3339 timestamp, as `Date.parse` reads the server's. -fn timestamp_ms(text: &str) -> Option { +pub(crate) fn timestamp_ms(text: &str) -> Option { let bytes = text.as_bytes(); let number = |from: usize, len: usize| -> Option { let digits = text.get(from..from + len)?; @@ -420,6 +421,25 @@ impl ShellState { ), pr: pr_badge(thread), section, + model: thread + .model_selection + .model + .as_str() + .unwrap_or_default() + .to_owned(), + instance_history: thread.provider_instance_history.clone().unwrap_or_default(), + pr_lines: thread + .pull_requests + .as_ref() + .map_or_else(Vec::new, |links| thread_card::pr_lines(&links.0)), + last_error: thread + .last_error + .as_deref() + .filter(|error| !error.is_empty()) + .map(|_| match thread.last_error_class { + Some(ThreadsEntryLastErrorClass::UsageLimit) => LastError::UsageLimit, + _ => LastError::Other, + }), }; let rows = SECTION_ORDER .into_iter() diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index f22fa4008f40..8cc295736df1 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -2,6 +2,7 @@ use crate::projects::{EnvironmentProjects, MachineKind, ProjectEntry}; use crate::providers::Catalog; use crate::strip::Branch; use crate::thread::Entry; +use crate::thread_card::PrLine; use crate::wire::generated::{ThreadsEntryLastErrorClass, ThreadsEntryStatus}; use std::fmt; @@ -69,6 +70,18 @@ pub struct ThreadRow { pub top: Option, pub pr: Option, pub section: Section, + /// What the hover card adds: the stored model slug, past instances, pull requests, the error. + pub model: String, + pub instance_history: Vec, + pub pr_lines: Vec, + pub last_error: Option, +} + +/// The card's error row: `usage_limit` is a warning, any other class an error. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LastError { + UsageLimit, + Other, } /// A pull request's state as the badge tones it; `Pending` has no snapshot yet. diff --git a/apps/desktop-gpui/src/thread_card.rs b/apps/desktop-gpui/src/thread_card.rs new file mode 100644 index 000000000000..3b782cfc46ca --- /dev/null +++ b/apps/desktop-gpui/src/thread_card.rs @@ -0,0 +1,331 @@ +//! What the sidebar thread's hover card lists beyond the row itself: the pull request lines of +//! `ThreadPullRequestsMiniList` and the "Handed off from" names of `SidebarThreadTooltip`. + +use crate::shell::timestamp_ms; +use crate::status::PrState; +use crate::wire::generated::{ + PullRequestsItem, PullRequestsItemSource as LinkSource, SnapshotState, +}; +use std::collections::{HashMap, HashSet}; + +/// How a chain of pull requests came to be: the host's own stack, or one derived from +/// head and base branches. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChainKind { + Native, + Derived, +} + +/// One `ThreadPullRequestMiniListItem`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PrLine { + pub number: u64, + /// `Pending` while the link has no snapshot yet; it shows the plain pull request glyph. + pub state: PrState, + /// The snapshot's title, else the repository. + pub title: String, + pub depth: usize, + /// On a chain's first line: its kind and how many pull requests it holds. + pub stack: Option<(ChainKind, usize)>, +} + +struct Chain { + kind: ChainKind, + layers: Vec, + depths: Vec, +} + +fn repository_key(link: &PullRequestsItem) -> String { + format!( + "{}/{}", + link.host.trim().to_lowercase(), + link.repository.trim().to_lowercase() + ) +} + +fn key_of(link: &PullRequestsItem) -> String { + format!("{}#{}", repository_key(link), link.number) +} + +fn latest_updated_at(link: &PullRequestsItem) -> i64 { + let value = link + .snapshot + .as_ref() + .and_then(|snapshot| snapshot.updated_at.as_deref()) + .unwrap_or(&link.linked_at); + timestamp_ms(value).unwrap_or(0) +} + +/// `resolveThreadPullRequestChains` over the visible links. +fn chains(visible: &[&PullRequestsItem]) -> Vec { + let mut chains = Vec::new(); + let mut placed: HashSet = HashSet::new(); + + let mut stacks: Vec<(String, Vec)> = Vec::new(); + for (index, link) in visible.iter().enumerate() { + let Some(stack) = &link.stack else { continue }; + let key = format!("{}#stack:{}", repository_key(link), stack.id); + match stacks.iter_mut().find(|(known, _)| *known == key) { + Some((_, members)) => members.push(index), + None => stacks.push((key, vec![index])), + } + } + for (_, mut members) in stacks { + // Saved snapshots can lag behind newer members of the same stack. + let numbers: HashSet = members.iter().map(|&i| visible[i].number.get()).collect(); + let layers_of = |i: usize| &visible[i].stack.as_ref().expect("stack member").layers; + let coverage = |layers: &[crate::wire::generated::StackLayersItem]| { + layers + .iter() + .filter(|layer| numbers.contains(&layer.number.get())) + .count() + }; + let mut layers = layers_of(members[0]); + let mut best = coverage(layers); + for &member in &members[1..] { + let candidate = layers_of(member); + let covered = coverage(candidate); + if covered > best || (covered == best && candidate.len() > layers.len()) { + layers = candidate; + best = covered; + } + } + let order: HashMap = layers + .iter() + .enumerate() + .map(|(index, layer)| (layer.number.get(), index)) + .collect(); + members.sort_by_key(|&i| { + order + .get(&visible[i].number.get()) + .copied() + .unwrap_or(layers.len()) + }); + placed.extend(members.iter().map(|&i| key_of(visible[i]))); + chains.push(Chain { + kind: ChainKind::Native, + depths: (0..members.len()).collect(), + layers: members, + }); + } + + let remaining: Vec = (0..visible.len()) + .filter(|&i| !placed.contains(&key_of(visible[i]))) + .collect(); + let branch_key = |i: usize, branch: &str| format!("{}:{branch}", repository_key(visible[i])); + // A head branch shared by several pull requests names no single parent. + let mut by_head: HashMap> = HashMap::new(); + for &i in &remaining { + let Some(snapshot) = &visible[i].snapshot else { + continue; + }; + let key = branch_key(i, &snapshot.head_branch); + let duplicate = by_head.contains_key(&key); + by_head.insert(key, if duplicate { None } else { Some(i) }); + } + let parent_of = |i: usize| -> Option { + let snapshot = visible[i].snapshot.as_ref()?; + by_head + .get(&branch_key(i, &snapshot.base_branch)) + .copied() + .flatten() + .filter(|&parent| parent != i) + }; + let mut children: HashMap> = HashMap::new(); + for &i in &remaining { + if let Some(parent) = parent_of(i) { + children.entry(key_of(visible[parent])).or_default().push(i); + } + } + for &base in &remaining { + if parent_of(base).is_some() { + continue; + } + let (mut layers, mut depths) = (Vec::new(), Vec::new()); + let mut pending = vec![(base, 0usize)]; + while let Some((next, depth)) = pending.pop() { + let key = key_of(visible[next]); + // Two links with one identity would otherwise share a child list and expand forever. + if !placed.insert(key.clone()) { + continue; + } + layers.push(next); + depths.push(depth); + for &child in children.get(&key).into_iter().flatten().rev() { + pending.push((child, depth + 1)); + } + } + if !layers.is_empty() { + chains.push(Chain { + kind: ChainKind::Derived, + layers, + depths, + }); + } + } + for &i in &remaining { + if !placed.contains(&key_of(visible[i])) { + chains.push(Chain { + kind: ChainKind::Derived, + layers: vec![i], + depths: vec![0], + }); + } + } + chains +} + +fn state_of(link: &PullRequestsItem) -> PrState { + match &link.snapshot { + None => PrState::Pending, + Some(snapshot) => match snapshot.state { + SnapshotState::Open if snapshot.is_draft => PrState::Draft, + SnapshotState::Open => PrState::Open, + SnapshotState::Closed => PrState::Closed, + SnapshotState::Merged => PrState::Merged, + }, + } +} + +/// `visibleThreadPullRequests`, `resolveThreadPullRequestChains` and `pullRequestListLines`: +/// chains newest first, each from its base up, indented by depth. +pub fn pr_lines(links: &[PullRequestsItem]) -> Vec { + let visible: Vec<&PullRequestsItem> = links + .iter() + .filter(|link| link.source != LinkSource::StackDismissed) + .collect(); + let mut chains = chains(&visible); + let newest = |chain: &Chain| { + chain + .layers + .iter() + .map(|&i| latest_updated_at(visible[i])) + .max() + .unwrap_or(0) + }; + chains.sort_by_key(|chain| std::cmp::Reverse(newest(chain))); + chains + .iter() + .flat_map(|chain| { + let visible = &visible; + chain.layers.iter().enumerate().map(move |(position, &i)| { + let link = visible[i]; + PrLine { + number: link.number.get(), + state: state_of(link), + title: link + .snapshot + .as_ref() + .map_or_else(|| link.repository.clone(), |s| s.title.clone()), + depth: chain.depths[position], + stack: (position == 0 && chain.layers.len() > 1) + .then_some((chain.kind, chain.layers.len())), + } + }) + }) + .collect() +} + +/// The instances a thread moved off, as the card names them: its history without the current +/// instance, each by display name or, unknown, by id. +pub fn previous_providers( + history: &[String], + current: &str, + display_name: impl Fn(&str) -> Option, +) -> Vec { + history + .iter() + .filter(|id| id.as_str() != current) + .map(|id| display_name(id).unwrap_or_else(|| id.clone())) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, json}; + + fn link(number: u64, head: &str, base: &str, updated: &str) -> Value { + json!({ + "host": "github.com", "linkedAt": "2026-01-01T00:00:00.000Z", + "number": number, "repository": "o/r", "source": "agent", + "url": format!("https://github.com/o/r/pull/{number}"), + "snapshot": { + "baseBranch": base, "headBranch": head, "isDraft": false, "state": "open", + "syncedAt": "2026-01-01T00:00:00.000Z", "title": format!("PR {number}"), + "updatedAt": updated, + }, + "stack": null, + }) + } + + fn lines(links: Vec) -> Vec { + pr_lines(&serde_json::from_value::>(links.into()).expect("links")) + } + + fn shape(lines: &[PrLine]) -> Vec<(u64, usize)> { + lines.iter().map(|l| (l.number, l.depth)).collect() + } + + #[test] + fn branches_chain_by_head_and_base_with_depth() { + let result = lines(vec![ + link(3, "c", "b", "2026-01-03T00:00:00.000Z"), + link(1, "a", "main", "2026-01-03T00:00:00.000Z"), + link(2, "b", "a", "2026-01-03T00:00:00.000Z"), + ]); + assert_eq!(shape(&result), [(1, 0), (2, 1), (3, 2)]); + assert_eq!(result[0].stack, Some((ChainKind::Derived, 3))); + assert_eq!(result[1].stack, None); + } + + #[test] + fn chains_list_the_most_recently_updated_first() { + let result = lines(vec![ + link(1, "a", "main", "2026-01-01T00:00:00.000Z"), + link(2, "x", "main", "2026-02-01T00:00:00.000Z"), + ]); + assert_eq!(shape(&result), [(2, 0), (1, 0)]); + assert!(result.iter().all(|line| line.stack.is_none())); + } + + #[test] + fn native_stacks_follow_the_stack_layers_and_dismissed_links_hide() { + let layers = json!([ + {"headBranch": "a", "number": 5, "state": "open"}, + {"headBranch": "b", "number": 4, "state": "open"}, + ]); + let stacked = |number: u64| { + let mut value = link(number, "h", "main", "2026-01-01T00:00:00.000Z"); + value["stack"] = json!({ + "base": "main", "id": "s", "kind": "native", "layers": layers, + "number": 5, "url": "https://github.com/o/r/pull/5", + }); + value + }; + let mut dismissed = link(9, "z", "main", "2026-03-01T00:00:00.000Z"); + dismissed["source"] = "stack-dismissed".into(); + let result = lines(vec![stacked(4), stacked(5), dismissed]); + assert_eq!(shape(&result), [(5, 0), (4, 1)]); + assert_eq!(result[0].stack, Some((ChainKind::Native, 2))); + } + + #[test] + fn a_link_without_a_snapshot_is_pending_and_titled_by_its_repository() { + let mut bare = link(7, "a", "main", "2026-01-01T00:00:00.000Z"); + bare["snapshot"] = Value::Null; + let result = lines(vec![bare]); + assert_eq!(result[0].state, PrState::Pending); + assert_eq!(result[0].title, "o/r"); + } + + #[test] + fn handed_off_names_skip_the_current_instance() { + let history = ["codex", "claude_work", "mystery"].map(str::to_owned); + let names = previous_providers(&history, "codex", |id| { + (id == "claude_work").then(|| "Work".to_owned()) + }); + assert_eq!(names, ["Work", "mystery"]); + assert_eq!(names.join(", "), "Work, mystery"); + } +} diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index fcfb570ff8f6..34b3ccbc2d34 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -4,12 +4,15 @@ use crate::history::History; use crate::minimap; use crate::projects::{self, EnvironmentProjects, MachineKind, ProjectEntry}; use crate::providers::{Catalog, Mark, Trigger}; -use crate::status::{PrBadge, PrState, Section, ShellView, Status, ThreadRow, TopStatus}; +use crate::status::{ + LastError, PrBadge, PrState, Section, ShellView, Status, ThreadRow, TopStatus, +}; use crate::strip::{ self, Branch, Defaults, EnvMode, HOST_SHORTCUT, PERMISSIONS_SHORTCUT, RuntimeMode, WORKSPACE_SHORTCUT, }; use crate::thread::{Entry, Kind}; +use crate::thread_card::{self, ChainKind, PrLine}; use gpui::{ Anchor, Animation, AnimationExt as _, AnyElement, App, Bounds, Context, DragMoveEvent, Entity, Focusable as _, FollowMode, FontWeight, Hsla, IntoElement, Keystroke, ListAlignment, @@ -742,14 +745,33 @@ impl MainView { .providers .as_ref() .and_then(|catalog| catalog.instance(&row.provider_instance)); + let anchor = Rc::new(Cell::new(Bounds::default())); + let card = ThreadCard::new( + row, + project, + provider.as_ref(), + self.providers.as_ref(), + match &self.status { + Status::Connected { label, machine, .. } => { + Some((strip::environment_label(label), *machine)) + } + _ => None, + }, + ); thread_row( row, project, provider, selected, self.rendered_sidebar_width.get(), + anchor.clone(), cx, ) + .tooltip(move |window, cx| { + let card = card.clone(); + let place = card_offset(anchor.get(), window.mouse_position()); + cx.new(|_| ThreadHoverCard { card, place }).into() + }) .on_click(cx.listener(move |this, _, _, cx| this.open_thread(Some(id.clone()), cx))) } @@ -2574,12 +2596,19 @@ fn pr_color(state: PrState, dark: bool) -> Option { } fn pr_icon(badge: &PrBadge) -> Icons { - match (badge.stack, badge.state) { - (true, _) => Icons::Layers, - (false, PrState::Draft) => Icons::GitPullRequestDraft, - (false, PrState::Closed) => Icons::GitPullRequestClosed, - (false, PrState::Merged) => Icons::GitMerge, - (false, _) => Icons::GitPullRequestArrow, + if badge.stack { + Icons::Layers + } else { + pr_state_icon(badge.state) + } +} + +fn pr_state_icon(state: PrState) -> Icons { + match state { + PrState::Draft => Icons::GitPullRequestDraft, + PrState::Closed => Icons::GitPullRequestClosed, + PrState::Merged => Icons::GitMerge, + PrState::Pending | PrState::Open => Icons::GitPullRequestArrow, } } @@ -2639,6 +2668,229 @@ fn card_action( .tooltip(tooltip(label)) } +/// What `SidebarThreadTooltip` shows for one thread, resolved when the row is built. +#[derive(Clone)] +struct ThreadCard { + title: String, + project: Option<(Option, String)>, + environment: Option<(String, MachineKind)>, + branch: Option, + provider: Option, + handed_off_from: Vec, + last_error: Option, + pr_lines: Vec, +} + +#[derive(Clone)] +struct CardProvider { + /// Without the chip unless the instance has an accent color, like `showBadge` on the web. + mark: Mark, + label: String, +} + +impl ThreadCard { + fn new( + row: &ThreadRow, + project: Option<&ProjectEntry>, + provider: Option<&Mark>, + catalog: Option<&Catalog>, + environment: Option<(String, MachineKind)>, + ) -> Self { + let name = project_name(row, project); + let model = catalog.map(|catalog| catalog.model_label(&row.provider_instance, &row.model)); + let provider = provider.zip(model).map(|(mark, model)| CardProvider { + label: match &mark.badge { + Some(_) => format!("{model} \u{b7} {}", mark.display_name), + None => model, + }, + mark: Mark { + badge: mark.badge.clone().filter(|badge| badge.accent.is_some()), + ..mark.clone() + }, + }); + Self { + title: row.title.clone(), + project: (!name.is_empty()).then(|| (project.cloned(), name.to_owned())), + environment, + branch: row.branch.clone().filter(|branch| !branch.is_empty()), + provider, + handed_off_from: thread_card::previous_providers( + &row.instance_history, + &row.provider_instance, + |id| { + catalog + .and_then(|catalog| catalog.instance(id)) + .map(|mark| mark.display_name) + }, + ), + last_error: row.last_error, + pr_lines: row.pr_lines.clone(), + } + } +} + +/// GPUI places a tooltip at the pointer, so the card shifts itself to the trigger's right edge +/// (`side="right" align="start" sideOffset={4}`). +fn card_offset(anchor: Bounds, mouse: gpui::Point) -> gpui::Point { + gpui::point( + anchor.right() + px(4.) - (mouse.x + px(1.)), + anchor.top() - (mouse.y + px(1.)), + ) +} + +/// The web's `ThreadHoverCard` in a `ThreadHoverCardPopup`. +struct ThreadHoverCard { + card: ThreadCard, + place: gpui::Point, +} + +impl Render for ThreadHoverCard { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let dark = theme.is_dark(); + let (muted, foreground) = (theme.muted_foreground, theme.foreground); + let text = foreground.opacity(0.75); + let card = &self.card; + let row = |icon: AnyElement, label: AnyElement| { + h_flex() + .min_w_0() + .items_center() + .gap_2() + .child(icon) + .child(label) + }; + let label = |text_color: Hsla, content: String| { + div() + .min_w_0() + .truncate() + .text_color(text_color) + .child(content) + .into_any_element() + }; + let glyph = |icon: Icons, color: Hsla| { + Icon::new(icon) + .size(px(12.)) + .flex_shrink_0() + .text_color(color) + .into_any_element() + }; + let mut rows = v_flex().gap(px(6.)).pl(px(2.)).text_xs().text_color(muted); + if let Some((entry, name)) = &card.project { + rows = rows.child(row( + project_badge(entry.as_ref(), dark, theme.mono_font_family.clone()) + .size(px(12.)) + .into_any_element(), + label(text, name.clone()), + )); + } + if let Some((name, machine)) = &card.environment { + rows = rows.child(row( + glyph(machine_icon(*machine), muted), + label(text, name.clone()), + )); + } + if let Some(branch) = &card.branch { + rows = rows.child(row( + glyph(Icons::GitBranch, muted), + label(text, middle_truncate(branch, 40)), + )); + } + if let Some(provider) = &card.provider { + rows = rows.child(row( + provider_mark(&provider.mark, 0.6, cx).into_any_element(), + label(text, provider.label.clone()), + )); + } + if !card.handed_off_from.is_empty() { + rows = rows.child(row( + glyph(Icons::ArrowRightLeft, muted), + label( + text, + format!("Handed off from {}", card.handed_off_from.join(", ")), + ), + )); + } + if let Some(error) = card.last_error { + let (color, message) = match error { + LastError::UsageLimit => ( + rgb(if dark { 0xfbbf24 } else { 0xb45309 }), + "Usage limit reached", + ), + LastError::Other => ( + rgb(if dark { 0xf87171 } else { 0xdc2626 }), + "Error occurred", + ), + }; + let color: Hsla = color.into(); + rows = rows.child(row( + glyph(Icons::CircleAlert, color), + label(color, message.to_owned()), + )); + } + let footer = (!card.pr_lines.is_empty()).then(|| { + v_flex() + .gap_1() + .pt_2() + .pl(px(2.)) + .border_t_1() + .border_color(theme.border.opacity(0.6)) + .text_xs() + .text_color(muted) + .children(card.pr_lines.iter().map(|line| { + let tone = pr_color(line.state, dark).unwrap_or(muted); + h_flex() + .min_w_0() + .items_center() + .gap_2() + .pl(px(line.depth.min(3) as f32 * 12.)) + .child(glyph(pr_state_icon(line.state), tone)) + .child( + div() + .flex_shrink_0() + .font_family(theme.mono_font_family.clone()) + .child(format!("#{}", line.number)), + ) + .child(label(text, line.title.clone())) + .children(line.stack.map(|(kind, size)| { + let word = match kind { + ChainKind::Native => "stack", + ChainKind::Derived => "chain", + }; + div() + .ml_auto() + .flex_shrink_0() + .pl_1() + .text_size(px(10.)) + .child(format!("{word} \u{b7} {size}")) + })) + })) + }); + div().relative().left(self.place.x).top(self.place.y).child( + v_flex() + .max_w(px(320.)) + .gap_2() + .px(px(10.)) + .py(px(8.)) + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .shadow_lg() + .child( + div() + .min_w_0() + .truncate() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child(card.title.clone()), + ) + .child(rows) + .children(footer), + ) + } +} + /// The group's label, else the project's own title. fn project_name<'a>(row: &'a ThreadRow, entry: Option<&'a ProjectEntry>) -> &'a str { entry.map_or(row.project.as_str(), |entry| entry.title.as_str()) @@ -2652,6 +2904,7 @@ fn thread_row( provider: Option, selected: bool, sidebar_width: f32, + anchor: Rc>>, cx: &App, ) -> gpui::Stateful { let theme = cx.theme(); @@ -2705,6 +2958,12 @@ fn thread_row( .when(!selected, |card| { card.hover(|style| style.bg(theme.list_hover)) }) + // Where the hover card anchors: the web's popup sits at this card's right edge. + .child( + canvas(move |bounds, _, _| anchor.set(bounds), |_, _, _, _| {}) + .absolute() + .size_full(), + ) .child( h_flex() .h(px(20.)) @@ -2886,6 +3145,10 @@ mod tests { top: None, pr: None, section: Section::Active, + model: String::new(), + instance_history: Vec::new(), + pr_lines: Vec::new(), + last_error: None, } } From c450ac3e3a3edd0d10eb62cd2de607965ac12a52 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 12:17:09 -0500 Subject: [PATCH 26/82] feat(desktop-gpui): the sidebar brand links home --- apps/desktop-gpui/src/ui.rs | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 34b3ccbc2d34..3827c5630c21 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -371,6 +371,21 @@ impl MainView { self.apply_route(route, cx); } + /// The web's brand link to `/`, which opens a new draft in the most recently updated project. + fn go_home(&mut self, cx: &mut Context) { + let project = self + .shell() + .and_then(|shell| active_project(None, shell)) + .map(|entry| entry.id.clone()) + .or_else(|| self.selected_project.clone()); + let route = Route { + project, + thread: None, + }; + self.history.visit(route.clone()); + self.apply_route(route, cx); + } + /// Asks for the branch of the active project's root once per connection. fn request_branch(&mut self) { let Some(entry) = self @@ -493,10 +508,16 @@ impl MainView { .when(open, |bar| { bar.child( h_flex() + .id("sidebar-brand") .ml_1() + .h(px(28.)) + .px_1() .gap_1() + .rounded_md() .text_sm() .text_color(brand) + .cursor_pointer() + .on_click(cx.listener(|this, _, _, cx| this.go_home(cx))) .child(div().font_weight(FontWeight::BOLD).child("T3")) .child( div() From 75fc0e3c6d3eb7454df00af0024f10c885ddb120 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 12:24:39 -0500 Subject: [PATCH 27/82] feat(desktop-gpui): thread details card with open in editor and changes --- apps/desktop-gpui/src/assets.rs | 10 +- apps/desktop-gpui/src/connection.rs | 195 +++++++++- apps/desktop-gpui/src/details.rs | 527 ++++++++++++++++++++++++++++ apps/desktop-gpui/src/main.rs | 3 + apps/desktop-gpui/src/providers.rs | 20 ++ apps/desktop-gpui/src/rpc.rs | 18 + apps/desktop-gpui/src/shell.rs | 5 + apps/desktop-gpui/src/status.rs | 12 + apps/desktop-gpui/src/ui.rs | 448 ++++++++++++++++++++++- 9 files changed, 1230 insertions(+), 8 deletions(-) create mode 100644 apps/desktop-gpui/src/details.rs diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 152514ce65c6..10d07ccbd1c9 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -40,7 +40,11 @@ gpui_kit_assets::icon_assets!( GitPullRequestClosed, GitMerge, Layers, - ArrowRightLeft + ArrowRightLeft, + SquareMenu, + ChevronDown, + SquareArrowOutUpRight, + FileDiff ] ); @@ -185,6 +189,10 @@ mod tests { fn every_catalog_icon_the_app_uses_loads() { for icon in [ IconName::Paperclip, + IconName::SquareMenu, + IconName::ChevronDown, + IconName::SquareArrowOutUpRight, + IconName::FileDiff, IconName::Pin, IconName::PinOff, IconName::Search, diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 4d1595ddf02c..16ad89d5ea61 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -1,12 +1,13 @@ //! The authenticated RPC WebSocket: connect, keep alive, reconnect. use crate::auth::{self, AuthError, Credential}; +use crate::details::VcsStatus; use crate::electron_dpop; use crate::projects::MachineKind; use crate::providers::Catalog; use crate::rpc::{ - Event, PING_FRAME, ProjectsMutate, ServerGetConfig, Session, SubscribeShell, SubscribeThread, - VcsListRefs, + Event, PING_FRAME, ProjectsMutate, ServerGetConfig, Session, ShellOpenInEditor, SubscribeShell, + SubscribeThread, SubscribeVcsStatus, VcsListRefs, }; use crate::shell::ShellState; use crate::status::{Status, StatusSender, publish}; @@ -51,6 +52,13 @@ pub enum Action { }, /// The thread whose transcript the window shows; `None` closes the subscription. SelectThread(Option), + /// The directory whose version control status the details card shows; `None` stops watching. + WatchVcs(Option), + /// Launches `editor` (an `EditorId`) on the server's machine at `cwd`. + OpenInEditor { + cwd: String, + editor: String, + }, } pub type ActionSender = async_channel::Sender; @@ -63,6 +71,8 @@ impl Action { Self::SetProjectPinned { pinned: false, .. } => "unpin project", Self::LoadBranch { .. } => "load the branch", Self::SelectThread(_) => "open the thread", + Self::WatchVcs(_) => "watch the version control status", + Self::OpenInEditor { .. } => "open in the editor", } } } @@ -169,6 +179,7 @@ pub async fn run( let mut bearer: Option = None; // Outlives a socket: a reconnect subscribes to the thread again. let mut selected: Option = None; + let mut watched: Option = None; let mut backoff = Backoff::new(); loop { publish(status, Status::Connecting); @@ -184,7 +195,14 @@ pub async fn run( }, ); backoff.reset(); - serve(socket, environment, actions, &mut selected, status).await + serve( + socket, + environment, + actions, + (&mut selected, &mut watched), + status, + ) + .await } Err(message) => message, }; @@ -251,7 +269,7 @@ async fn serve( socket: Socket, environment: &Environment, actions: &ActionReceiver, - selected: &mut Option, + (selected, watched): (&mut Option, &mut Option), status: &StatusSender, ) -> String { let (mut sink, mut stream) = socket.split(); @@ -276,8 +294,10 @@ async fn serve( }; // Whatever was clicked while disconnected is stale by now, except which thread is open. while let Ok(action) = actions.try_recv() { - if let Action::SelectThread(id) = action { - *selected = id; + match action { + Action::SelectThread(id) => *selected = id, + Action::WatchVcs(cwd) => *watched = cwd, + _ => {} } } let mut threads = Threads::default(); @@ -287,6 +307,13 @@ async fn serve( { return error; } + let mut vcs = VcsWatch::default(); + if let Err(error) = vcs + .select(watched.as_deref(), &mut session, &mut sink) + .await + { + return error; + } let mut pending: HashMap = HashMap::new(); // Branch lookups by request id, with the workspace root each was for. let mut branches: HashMap = HashMap::new(); @@ -314,6 +341,21 @@ async fn serve( } continue; } + Action::WatchVcs(cwd) => { + *watched = cwd.clone(); + if let Err(error) = + vcs.select(cwd.as_deref(), &mut session, &mut sink).await + { + return error; + } + continue; + } + Action::OpenInEditor { cwd, editor } => session + .request::(&serde_json::json!({ + "cwd": cwd, + "editor": editor, + })) + .map(|(id, frame)| (id, frame, None)), Action::SetProjectPinned { project_id, pinned } => session .request::(&pin_mutation(project_id, *pinned, command_id())) .map(|(id, frame)| (id, frame, None)), @@ -359,6 +401,9 @@ async fn serve( let Some(event) = threads.route(event, status) else { continue; }; + let Some(event) = vcs.route(event, status) else { + continue; + }; if config_request.as_ref().is_some_and(|id| owns(&event, id)) { config_request = None; publish_catalog(event, status); @@ -485,6 +530,97 @@ impl Threads { } } +/// The open version control subscription, and the interrupted ones whose last frames still arrive. +#[derive(Default)] +struct VcsWatch { + open: Option, + stale: HashSet, +} + +struct OpenVcs { + cwd: String, + request_id: String, + status: Option, +} + +impl VcsWatch { + /// Switches the subscription to `wanted`, interrupting the previous one. + async fn select( + &mut self, + wanted: Option<&str>, + session: &mut Session, + sink: &mut Sink, + ) -> Result<(), String> { + if self.open.as_ref().map(|open| open.cwd.as_str()) == wanted { + return Ok(()); + } + if let Some(open) = self.open.take() { + let frame = session.interrupt(&open.request_id); + self.stale.insert(open.request_id); + sink.send(Message::text(frame)) + .await + .map_err(|error| error.to_string())?; + } + let Some(cwd) = wanted else { + return Ok(()); + }; + let (request_id, frame) = + session.request::(&serde_json::json!({ "cwd": cwd }))?; + self.open = Some(OpenVcs { + cwd: cwd.to_owned(), + request_id, + status: None, + }); + sink.send(Message::text(frame)) + .await + .map_err(|error| error.to_string()) + } + + /// Consumes the events of status requests, publishing the merged status; hands back the rest. + fn route(&mut self, event: Event, status: &StatusSender) -> Option { + let id = match &event { + Event::Item { request_id, .. } + | Event::Done { request_id, .. } + | Event::Failed { request_id, .. } => request_id.clone(), + _ => return Some(event), + }; + if self.stale.contains(&id) { + if !matches!(event, Event::Item { .. }) { + self.stale.remove(&id); + } + return None; + } + let Some(open) = self.open.as_mut().filter(|open| open.request_id == id) else { + return Some(event); + }; + let cwd = open.cwd.clone(); + match event { + Event::Item { value, .. } => { + if let Some(next) = VcsStatus::apply(open.status.as_ref(), &value) { + open.status = Some(next.clone()); + publish( + status, + Status::Vcs { + cwd, + status: Some(next), + }, + ); + } + } + Event::Failed { message, .. } => { + eprintln!("[rpc] could not watch the status of {cwd}: {message}"); + self.open = None; + publish(status, Status::Vcs { cwd, status: None }); + } + _ => { + self.open = None; + publish(status, Status::Vcs { cwd, status: None }); + } + } + None + } +} + fn command_id() -> String { let mut bytes = [0u8; 8]; // A failed read leaves zeros, and the server rejects a repeated command id rather than applying it twice. @@ -681,6 +817,53 @@ mod tests { )); } + #[test] + fn status_events_merge_and_the_stream_ending_clears_it() { + let (status, published) = async_channel::unbounded(); + let item = |id: &str, value: serde_json::Value| Event::Item { + request_id: id.to_owned(), + value, + }; + let snapshot = serde_json::json!({ + "_tag": "snapshot", + "local": {"isRepo": true, "refName": "main", "workingTree": {"insertions": 1, "deletions": 0}}, + "remote": null, + }); + let mut watch = VcsWatch { + open: Some(OpenVcs { + cwd: "/repo".to_owned(), + request_id: "3".to_owned(), + status: None, + }), + stale: HashSet::from(["2".to_owned()]), + }; + assert!(watch.route(item("9", snapshot.clone()), &status).is_some()); + assert!(watch.route(item("2", snapshot.clone()), &status).is_none()); + assert!(published.try_recv().is_err()); + assert!(watch.route(item("3", snapshot), &status).is_none()); + let Ok(Status::Vcs { + cwd, + status: Some(merged), + }) = published.try_recv() + else { + panic!("the open watch publishes its status"); + }; + assert_eq!( + (cwd.as_str(), merged.local.working_tree.insertions), + ("/repo", 1) + ); + let done = Event::Done { + request_id: "3".to_owned(), + value: serde_json::Value::Null, + }; + assert!(watch.route(done, &status).is_none()); + assert!(watch.open.is_none()); + assert!(matches!( + published.try_recv(), + Ok(Status::Vcs { status: None, .. }) + )); + } + #[test] fn socket_url_carries_protocol_and_escaped_ticket() { let url = socket_url("http://127.0.0.1:13773", "a b&c").expect("url"); diff --git a/apps/desktop-gpui/src/details.rs b/apps/desktop-gpui/src/details.rs new file mode 100644 index 000000000000..0031dadcd6c7 --- /dev/null +++ b/apps/desktop-gpui/src/details.rs @@ -0,0 +1,527 @@ +//! The thread details card's logic: what it shows (`ThreadDetailsPanel.tsx`), how it fits +//! (`threadDetailsCardLayout.ts`), the editors it opens (`editor.ts`, `editorPreferences.ts`), and +//! the version control status behind the branch and changes rows (`GitActionsControl.logic.ts`). +//! Only the default item visibility applies: client settings are not on the wire. + +use serde_json::Value; + +/// The web's default binding for `threadPanel.toggle` and `editor.openFavorite` on macOS. +pub const TOGGLE_SHORTCUT: &str = "\u{21e7}\u{2318}B"; +pub const OPEN_FAVORITE_SHORTCUT: &str = "\u{2318}O"; + +/// `THREAD_DETAILS_CARD_WIDTH` and `THREAD_DETAILS_CARD_GAP`. +pub const CARD_WIDTH: f32 = 280.; +pub const CARD_GAP: f32 = 12.; +/// `DETAILS_CARD_CLEARANCE`, and the chat lane's padding and minimum width (`canvasStore.ts`). +const CARD_CLEARANCE: f32 = 32.; +const LANE_PADDING: f32 = 48.; +const MIN_CHAT_WIDTH: f32 = 640.; +const MIN_CARD_HEIGHT: f32 = 160.; +/// A `h-8` row, and a section's `pt-2 pb-2.5` with its 1px divider when separated. +const ROW_HEIGHT: f32 = 32.; +const SECTION_PADDING: f32 = 18.; + +/// What room there is for the card in the main area, or `None` when it does not fit beside the +/// chat lane (the web falls back to a popover there). +pub fn card_height(container_width: f32, container_height: f32) -> Option { + let x = container_width - CARD_WIDTH - CARD_GAP; + if x - CARD_CLEARANCE - LANE_PADDING < MIN_CHAT_WIDTH { + return None; + } + let height = container_height - CARD_GAP * 2.; + (height >= MIN_CARD_HEIGHT).then_some(height) +} + +/// How much of the card shows: rows drop out as the available height shrinks. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Density { + Full, + Compact, + Essential, +} + +/// `resolveThreadDetailsCardDensity`; a zero content height means not measured, so it fits. +pub fn resolve_density(height: f32, full: f32, compact: f32) -> Density { + if full == 0. || full <= height { + Density::Full + } else if compact == 0. || compact <= height { + Density::Compact + } else { + Density::Essential + } +} + +/// What the card knows about its target. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Inputs { + /// The thread's worktree or the project's root. + pub has_cwd: bool, + pub is_repo: bool, + pub has_editors: bool, + /// A fresh draft or a thread with no run, with nothing to commit or push. + pub quiet_empty_draft: bool, +} + +/// The rows each section renders, as `ThreadDetailsPanel` flags them. Environment, scripts and +/// git actions are not in this client yet. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Show { + pub open_in: bool, + pub branch: bool, + pub changes: bool, +} + +impl Show { + pub fn resolve(density: Density, inputs: Inputs) -> Self { + Self { + open_in: inputs.has_cwd && inputs.has_editors && density != Density::Essential, + branch: inputs.has_cwd && inputs.is_repo, + changes: inputs.has_cwd && inputs.is_repo && !inputs.quiet_empty_draft, + } + } + + /// Nothing to render, so the card is left out rather than drawn empty. + pub fn is_empty(self) -> bool { + !(self.open_in || self.branch || self.changes) + } + + /// The card's content height at `density`, standing in for the web's measured heights. + pub fn content_height(self, density: Density) -> f32 { + let workspace = self.open_in as u8 as f32 * ROW_HEIGHT; + let version = (self.branch as u8 + self.changes as u8) as f32 * ROW_HEIGHT; + let sections = [workspace, version].into_iter().filter(|rows| *rows > 0.); + let count = sections.clone().count() as f32; + let divider = if density == Density::Full { + (count - 1.).max(0.) + } else { + 0. + }; + sections.sum::() + count * SECTION_PADDING + divider + } +} + +/// The density for `height`, from the content each density would render. +pub fn density_for(height: f32, inputs: Inputs) -> Density { + let heights = |density| Show::resolve(density, inputs).content_height(density); + resolve_density(height, heights(Density::Full), heights(Density::Compact)) +} + +/// `EDITORS` in `editor.ts`, in the order that picks the default. +pub const EDITORS: &[(&str, &str)] = &[ + ("cursor", "Cursor"), + ("trae", "Trae"), + ("kiro", "Kiro"), + ("vscode", "VS Code"), + ("vscode-insiders", "VS Code Insiders"), + ("vscodium", "VSCodium"), + ("zed", "Zed"), + ("antigravity", "Antigravity"), + ("idea", "IntelliJ IDEA"), + ("aqua", "Aqua"), + ("clion", "CLion"), + ("datagrip", "DataGrip"), + ("dataspell", "DataSpell"), + ("goland", "GoLand"), + ("phpstorm", "PhpStorm"), + ("pycharm", "PyCharm"), + ("rider", "Rider"), + ("rubymine", "RubyMine"), + ("rustrover", "RustRover"), + ("webstorm", "WebStorm"), + ("file-manager", "File Manager"), +]; + +pub const FILE_MANAGER: &str = "file-manager"; + +/// `editorLabelForPlatform` on macOS, where the file manager is Finder. +pub fn editor_label(id: &str) -> &'static str { + if id == FILE_MANAGER { + return "Finder"; + } + EDITORS + .iter() + .find(|(known, _)| *known == id) + .map_or("Editor", |(_, label)| label) +} + +/// The installed editors the picker lists, in `EDITORS` order; ids this client does not know are dropped. +pub fn installed_editors(available: &[String]) -> Vec<&'static str> { + EDITORS + .iter() + .filter(|(id, _)| available.iter().any(|a| a == id)) + .map(|(id, _)| *id) + .collect() +} + +/// `usePreferredEditor`: the last pick while it is installed, else the first installed editor. +pub fn preferred_editor(last: Option<&str>, installed: &[&'static str]) -> Option<&'static str> { + last.and_then(|last| installed.iter().copied().find(|id| *id == last)) + .or_else(|| installed.first().copied()) +} + +/// `formatDiffCount`: `Intl.NumberFormat` in en-US groups thousands with commas. +pub fn format_diff_count(count: u64) -> String { + let digits = count.to_string(); + let mut grouped = String::with_capacity(digits.len() + digits.len() / 3); + for (index, digit) in digits.chars().enumerate() { + if index > 0 && (digits.len() - index).is_multiple_of(3) { + grouped.push(','); + } + grouped.push(digit); + } + grouped +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Lines { + pub insertions: u64, + pub deletions: u64, +} + +impl Lines { + fn decode(value: &Value) -> Self { + Self { + insertions: value["insertions"].as_u64().unwrap_or(0), + deletions: value["deletions"].as_u64().unwrap_or(0), + } + } + + fn is_zero(self) -> bool { + self.insertions == 0 && self.deletions == 0 + } +} + +/// The local half of the `subscribeVcsStatus` stream (`VcsStatusLocalResult`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Local { + pub is_repo: bool, + pub has_primary_remote: bool, + pub is_default_ref: bool, + pub ref_name: Option, + pub has_working_tree_changes: bool, + pub working_tree: Lines, + pub branch_changes: Option, +} + +/// The remote half (`VcsStatusRemoteResult`); absent until the server has looked, which reads as +/// no upstream, as in `mergeGitStatusParts`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Remote { + pub has_upstream: bool, + pub ahead: u64, + pub behind: u64, + pub ahead_of_default: Option, + pub pr_open: bool, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct VcsStatus { + pub local: Local, + pub remote: Remote, +} + +impl Local { + fn decode(value: &Value) -> Option { + let flag = |name: &str| value[name].as_bool(); + Some(Self { + is_repo: flag("isRepo")?, + has_primary_remote: flag("hasPrimaryRemote").unwrap_or(false), + is_default_ref: flag("isDefaultRef").unwrap_or(false), + ref_name: value["refName"].as_str().map(str::to_owned), + has_working_tree_changes: flag("hasWorkingTreeChanges").unwrap_or(false), + working_tree: Lines::decode(&value["workingTree"]), + branch_changes: value + .get("branchChanges") + .filter(|changes| changes.is_object()) + .map(Lines::decode), + }) + } +} + +impl Remote { + fn decode(value: &Value) -> Self { + if !value.is_object() { + return Self::default(); + } + Self { + has_upstream: value["hasUpstream"].as_bool().unwrap_or(false), + ahead: value["aheadCount"].as_u64().unwrap_or(0), + behind: value["behindCount"].as_u64().unwrap_or(0), + ahead_of_default: value["aheadOfDefaultCount"].as_u64(), + pr_open: value["pr"]["state"] == "open", + } + } +} + +impl VcsStatus { + /// `mergeVcsStatusStream`: a snapshot replaces both halves, an update only its own. `None` + /// when the event is not one this client reads. + pub fn apply(current: Option<&Self>, event: &Value) -> Option { + let kept = |current: Option<&Self>| current.cloned().unwrap_or_default(); + match event["_tag"].as_str()? { + "snapshot" => Some(Self { + local: Local::decode(&event["local"])?, + remote: Remote::decode(&event["remote"]), + }), + "localUpdated" => Some(Self { + local: Local::decode(&event["local"])?, + remote: kept(current).remote, + }), + "remoteUpdated" => { + let base = match current { + Some(current) => current.clone(), + None => Self { + local: Local { + is_repo: true, + ..Local::default() + }, + remote: Remote::default(), + }, + }; + Some(Self { + local: base.local, + remote: Remote::decode(&event["remote"]), + }) + } + _ => None, + } + } + + /// `resolveChangesTotals`: a zero branch total must not hide a dirty working tree. + pub fn changes_totals(&self) -> Lines { + let tree = self.local.working_tree; + match self.local.branch_changes { + Some(branch) if !branch.is_zero() => branch, + _ if !tree.is_zero() => tree, + Some(branch) => branch, + None => tree, + } + } + + /// Whether `resolveQuickAction` would offer nothing to run, which with no work quiets a fresh draft. + pub fn quick_action_disabled(&self) -> bool { + let (local, remote) = (&self.local, &self.remote); + if local.ref_name.is_none() { + return true; + } + if local.has_working_tree_changes { + return false; + } + if !remote.has_upstream { + return local.has_primary_remote && remote.ahead == 0; + } + let diverged = remote.ahead > 0 && remote.behind > 0; + if diverged { + return true; + } + if remote.behind > 0 || remote.ahead > 0 { + return false; + } + if remote.pr_open { + return true; + } + let delta = remote.ahead_of_default.unwrap_or(remote.ahead) > 0; + !(delta && !local.is_default_ref) + } +} + +/// `quietEmptyDraft` without a git action in flight or a status error: a draft, or a thread that +/// has not run, hides the changes row until there is work. +pub fn quiet_empty_draft(has_run: bool, status: Option<&VcsStatus>) -> bool { + if has_run { + return false; + } + match status { + // Until the status loads there is nothing to show either way. + None => true, + Some(status) => { + let totals = status.changes_totals(); + status.quick_action_disabled() && totals.is_zero() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn inputs() -> Inputs { + Inputs { + has_cwd: true, + is_repo: true, + has_editors: true, + quiet_empty_draft: false, + } + } + + fn status(local: Value, remote: Value) -> Value { + json!({"_tag": "snapshot", "local": local, "remote": remote}) + } + + fn clean() -> Value { + json!({ + "isRepo": true, "hasPrimaryRemote": true, "isDefaultRef": false, + "refName": "feature", "hasWorkingTreeChanges": false, + "workingTree": {"files": [], "insertions": 0, "deletions": 0}, + }) + } + + fn remote(ahead: u64, behind: u64, upstream: bool) -> Value { + json!({"hasUpstream": upstream, "aheadCount": ahead, "behindCount": behind, "pr": null}) + } + + #[test] + fn the_card_fits_beside_the_chat_lane_or_not_at_all() { + // 48 + 640 + 32 + 280 + 12 is the narrowest main area. + assert_eq!(card_height(1012., 600.), Some(576.)); + assert_eq!(card_height(1011., 600.), None); + assert_eq!(card_height(1400., 183.), None); + assert_eq!(card_height(1400., 184.), Some(160.)); + } + + #[test] + fn density_drops_to_the_smaller_content_that_fits() { + assert_eq!(resolve_density(500., 400., 300.), Density::Full); + assert_eq!(resolve_density(350., 400., 300.), Density::Compact); + assert_eq!(resolve_density(200., 400., 300.), Density::Essential); + assert_eq!(resolve_density(10., 0., 0.), Density::Full); + // Three rows in two sections: 3 * 32 + 2 * 18 + a divider when full. + assert_eq!(density_for(133., inputs()), Density::Full); + assert_eq!(density_for(132., inputs()), Density::Compact); + assert_eq!(density_for(100., inputs()), Density::Essential); + } + + #[test] + fn sections_show_only_the_rows_that_work() { + let show = |density, change: fn(&mut Inputs)| { + let mut i = inputs(); + change(&mut i); + Show::resolve(density, i) + }; + assert_eq!( + show(Density::Full, |_| {}), + Show { + open_in: true, + branch: true, + changes: true + } + ); + assert!(!show(Density::Essential, |_| {}).open_in); + assert!(!show(Density::Full, |i| i.has_editors = false).open_in); + let not_repo = show(Density::Full, |i| i.is_repo = false); + assert!(not_repo.open_in && !not_repo.branch && !not_repo.changes); + let quiet = show(Density::Full, |i| i.quiet_empty_draft = true); + assert!(quiet.branch && !quiet.changes); + assert!(show(Density::Full, |i| i.has_cwd = false).is_empty()); + } + + #[test] + fn editors_follow_the_web_order_and_preference() { + let available: Vec = ["zed", "cursor", "file-manager", "unknown"] + .map(str::to_owned) + .into(); + let installed = installed_editors(&available); + assert_eq!(installed, ["cursor", "zed", "file-manager"]); + assert_eq!(preferred_editor(Some("zed"), &installed), Some("zed")); + assert_eq!(preferred_editor(Some("vscode"), &installed), Some("cursor")); + assert_eq!(preferred_editor(None, &[]), None); + assert_eq!(editor_label("file-manager"), "Finder"); + assert_eq!(editor_label("vscode-insiders"), "VS Code Insiders"); + } + + #[test] + fn diff_counts_group_thousands() { + let shown: Vec<_> = [0, 7, 999, 1000, 12_345, 1_234_567] + .map(format_diff_count) + .into(); + assert_eq!(shown, ["0", "7", "999", "1,000", "12,345", "1,234,567"]); + } + + #[test] + fn the_stream_merges_like_the_web() { + let mut dirty = clean(); + dirty["hasWorkingTreeChanges"] = json!(true); + dirty["workingTree"] = json!({"files": [], "insertions": 4, "deletions": 2}); + let snapshot = + VcsStatus::apply(None, &status(clean(), remote(2, 0, true))).expect("snapshot"); + assert_eq!(snapshot.remote.ahead, 2); + let local = json!({"_tag": "localUpdated", "local": dirty}); + let updated = VcsStatus::apply(Some(&snapshot), &local).expect("local"); + assert_eq!(updated.remote.ahead, 2); + assert_eq!( + updated.changes_totals(), + Lines { + insertions: 4, + deletions: 2 + } + ); + let remote_event = json!({"_tag": "remoteUpdated", "remote": null}); + let reset = VcsStatus::apply(Some(&updated), &remote_event).expect("remote"); + assert_eq!(reset.remote, Remote::default()); + assert_eq!(reset.local, updated.local); + // A remote update before any snapshot starts from an empty repository. + assert!( + VcsStatus::apply(None, &remote_event) + .expect("remote") + .local + .is_repo + ); + assert!(VcsStatus::apply(None, &json!({"_tag": "other"})).is_none()); + } + + #[test] + fn a_zero_branch_total_does_not_hide_a_dirty_tree() { + let mut local = clean(); + local["branchChanges"] = json!({"baseRef": "main", "insertions": 0, "deletions": 0}); + local["workingTree"] = json!({"files": [], "insertions": 3, "deletions": 1}); + let dirty = VcsStatus::apply(None, &status(local.clone(), Value::Null)).expect("status"); + assert_eq!( + dirty.changes_totals(), + Lines { + insertions: 3, + deletions: 1 + } + ); + local["branchChanges"] = json!({"baseRef": "main", "insertions": 10, "deletions": 5}); + let branch = VcsStatus::apply(None, &status(local, Value::Null)).expect("status"); + assert_eq!( + branch.changes_totals(), + Lines { + insertions: 10, + deletions: 5 + } + ); + let none = VcsStatus::apply(None, &status(clean(), Value::Null)).expect("status"); + assert_eq!(none.changes_totals(), Lines::default()); + } + + #[test] + fn a_fresh_draft_is_quiet_until_there_is_work() { + let at = |local: Value, remote: Value| { + VcsStatus::apply(None, &status(local, remote)).expect("status") + }; + let synced = at(clean(), remote(0, 0, true)); + assert!(synced.quick_action_disabled()); + assert!(quiet_empty_draft(false, Some(&synced))); + assert!(quiet_empty_draft(false, None)); + // A thread that has run keeps its rows. + assert!(!quiet_empty_draft(true, Some(&synced))); + let mut dirty = clean(); + dirty["hasWorkingTreeChanges"] = json!(true); + assert!(!quiet_empty_draft( + false, + Some(&at(dirty, remote(0, 0, true))) + )); + assert!(!quiet_empty_draft( + false, + Some(&at(clean(), remote(1, 0, true))) + )); + // No upstream and no commits to push is still nothing to do. + assert!(at(clean(), remote(0, 0, false)).quick_action_disabled()); + assert!(at(clean(), remote(1, 1, true)).quick_action_disabled()); + let mut detached = clean(); + detached["refName"] = Value::Null; + assert!(at(detached, remote(0, 0, true)).quick_action_disabled()); + } +} diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index ccaea439089e..e18576232a97 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -3,6 +3,7 @@ mod auth; mod catalog; mod config; mod connection; +mod details; mod electron_dpop; mod environments; mod history; @@ -96,6 +97,8 @@ fn main() { KeyBinding::new("cmd-b", ui::ToggleSidebar, None), KeyBinding::new("escape", ui::DismissSidebar, Some(ui::OVERLAY_CONTEXT)), KeyBinding::new("shift-cmd-a", ui::OpenPermissions, None), + KeyBinding::new("shift-cmd-b", ui::ToggleDetails, None), + KeyBinding::new("cmd-o", ui::OpenFavoriteEditor, None), KeyBinding::new("cmd-[", ui::GoBack, None), KeyBinding::new("cmd-]", ui::GoForward, None), ]); diff --git a/apps/desktop-gpui/src/providers.rs b/apps/desktop-gpui/src/providers.rs index 6e66cc34e3f1..30bdcba3c337 100644 --- a/apps/desktop-gpui/src/providers.rs +++ b/apps/desktop-gpui/src/providers.rs @@ -241,6 +241,8 @@ struct Config { providers: Vec, #[serde(default)] settings: Settings, + #[serde(default, rename = "availableEditors", deserialize_with = "lenient")] + available_editors: Vec, } /// The provider snapshots and settings of one environment. @@ -248,6 +250,7 @@ struct Config { pub struct Catalog { providers: Vec, settings: Settings, + available_editors: Vec, } /// The two-letter instance chip at the icon's bottom-right. @@ -291,13 +294,20 @@ impl Catalog { let Config { providers, settings, + available_editors, } = serde_json::from_value(value).map_err(|error| error.to_string())?; Ok(Self { providers, settings, + available_editors, }) } + /// The editor ids the server found on its machine (`server.getConfig` `availableEditors`). + pub fn available_editors(&self) -> &[String] { + &self.available_editors + } + /// The workspace and permission defaults of a fresh draft in `project`: its override, then the server settings. pub fn defaults(&self, project: Option<&str>) -> Defaults { let overrides = project.and_then(|id| self.settings.project_settings_overrides.get(id)); @@ -904,6 +914,16 @@ mod tests { Catalog::decode(json!({"providers": providers, "settings": settings})).unwrap() } + #[test] + fn the_installed_editors_ride_along_and_default_to_none() { + let with = Catalog::decode(json!({ + "providers": [], "settings": {}, "availableEditors": ["zed", "file-manager"], + })) + .unwrap(); + assert_eq!(with.available_editors(), ["zed", "file-manager"]); + assert!(catalog(vec![], json!({})).available_editors().is_empty()); + } + #[test] fn defaults_prefer_the_project_override() { let catalog = catalog( diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs index 479b2d55e4c3..0a0bde56b852 100644 --- a/apps/desktop-gpui/src/rpc.rs +++ b/apps/desktop-gpui/src/rpc.rs @@ -63,6 +63,24 @@ impl Method for VcsListRefs { type Success = Value; } +/// `shell.openInEditor`, a unary call that launches an editor on the server's machine. +pub struct ShellOpenInEditor; + +impl Method for ShellOpenInEditor { + const TAG: &'static str = "shell.openInEditor"; + type Payload = Value; + type Success = Value; +} + +/// `subscribeVcsStatus`, a stream of snapshot and update events; `details::VcsStatus` reads them. +pub struct SubscribeVcsStatus; + +impl Method for SubscribeVcsStatus { + const TAG: &'static str = "subscribeVcsStatus"; + type Payload = Value; + type Success = Value; +} + /// Request ids are bigints on the wire, so they travel as strings. fn id_text<'de, D: Deserializer<'de>>(deserializer: D) -> Result { match Value::deserialize(deserializer)? { diff --git a/apps/desktop-gpui/src/shell.rs b/apps/desktop-gpui/src/shell.rs index 211323404d20..d5083c5f5cde 100644 --- a/apps/desktop-gpui/src/shell.rs +++ b/apps/desktop-gpui/src/shell.rs @@ -440,6 +440,11 @@ impl ShellState { Some(ThreadsEntryLastErrorClass::UsageLimit) => LastError::UsageLimit, _ => LastError::Other, }), + worktree_path: thread + .worktree_path + .clone() + .filter(|path| !path.trim().is_empty()), + has_run: thread.latest_run_id.is_some(), }; let rows = SECTION_ORDER .into_iter() diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index 8cc295736df1..05033fab57e3 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -1,3 +1,4 @@ +use crate::details::VcsStatus; use crate::projects::{EnvironmentProjects, MachineKind, ProjectEntry}; use crate::providers::Catalog; use crate::strip::Branch; @@ -35,6 +36,12 @@ pub enum Status { root: String, branch: Option, }, + /// The version control status of a directory the details card watches; `None` when the + /// subscription failed or ended. + Vcs { + cwd: String, + status: Option, + }, } /// Where the web sidebar lists a thread. @@ -75,6 +82,10 @@ pub struct ThreadRow { pub instance_history: Vec, pub pr_lines: Vec, pub last_error: Option, + /// The thread's own worktree, where its changes live; `None` works in the project root. + pub worktree_path: Option, + /// Whether the thread has ever run, which un-quiets the details card's changes row. + pub has_run: bool, } /// The card's error row: `usage_limit` is a warning, any other class an error. @@ -177,6 +188,7 @@ impl fmt::Display for Status { Self::Providers(_) => f.write_str("Providers"), Self::Thread { id, .. } => write!(f, "Thread {id}"), Self::Branch { root, .. } => write!(f, "Branch of {root}"), + Self::Vcs { cwd, .. } => write!(f, "Status of {cwd}"), } } } diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 3827c5630c21..5c72d515d797 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -1,5 +1,6 @@ use crate::assets::{MESSAGE_SQUARE_DASHED, STAGE_DEV, STAGE_DEV_BUTTON, provider_icon}; use crate::connection::{Action, ActionSender}; +use crate::details::{self, Density, Inputs, Show, VcsStatus}; use crate::history::History; use crate::minimap; use crate::projects::{self, EnvironmentProjects, MachineKind, ProjectEntry}; @@ -62,7 +63,9 @@ actions!( DismissSidebar, OpenPermissions, GoBack, - GoForward + GoForward, + ToggleDetails, + OpenFavoriteEditor ] ); @@ -183,6 +186,18 @@ pub struct MainView { branches: HashMap>, /// Roots whose branch was asked for on this connection. branches_requested: HashSet, + /// Threads (and drafts, by project) whose details card the user closed; it starts open. + details_closed: HashSet, + /// The version control status per directory the card watched, `None` where it failed. + vcs: HashMap>, + /// The directory the connection was last asked to watch. + vcs_watching: Option, + /// The editor last picked or launched, in place of the web's `t3code:last-editor`. + editor_pick: Option, + editor_menu_open: bool, + editor_menu: Entity, + /// The main area's width at the last render, for fitting the details card. + main_width: Cell, actions: ActionSender, } @@ -215,11 +230,17 @@ impl MainView { }) .detach(); let view = cx.weak_entity(); + let menu_view = view.clone(); let permissions = cx.new(|_| PermissionsPanel { view, selected: RuntimeMode::FullAccess, preview: None, }); + let editor_menu = cx.new(|_| EditorMenu { + view: menu_view, + editors: Vec::new(), + preferred: None, + }); Self { status, search: cx.new(|cx| InputState::new(window, cx).placeholder("Search")), @@ -253,6 +274,13 @@ impl MainView { providers: None, branches: HashMap::new(), branches_requested: HashSet::new(), + details_closed: HashSet::new(), + vcs: HashMap::new(), + vcs_watching: None, + editor_pick: None, + editor_menu_open: false, + editor_menu, + main_width: Cell::new(0.), actions, } } @@ -277,8 +305,13 @@ impl MainView { self.branches.insert(root, branch); return cx.notify(); } + if let Status::Vcs { cwd, status } = status { + self.vcs.insert(cwd, status); + return cx.notify(); + } if matches!(status, Status::Connecting) { self.branches_requested.clear(); + self.vcs.clear(); } self.status = status; // Thread activity reorders recency on every delta, so the default must not follow it. @@ -295,6 +328,7 @@ impl MainView { } } self.request_branch(); + self.sync_vcs(); cx.notify(); } @@ -350,6 +384,7 @@ impl MainView { self.selected_thread = route.thread; self.sync_thread(); self.request_branch(); + self.sync_vcs(); cx.notify(); } @@ -403,6 +438,101 @@ impl MainView { } } + /// Where the details card's state is kept: the open thread, or the project of a draft. + fn details_key(&self) -> Option { + if let Some(id) = &self.selected_thread { + return Some(id.clone()); + } + let shell = self.shell()?; + let entry = active_project(self.selected_project.as_deref(), shell)?; + Some(format!("draft:{}", entry.id)) + } + + fn details_open(&self) -> bool { + self.details_key() + .is_some_and(|key| !self.details_closed.contains(&key)) + } + + /// The directory the card works on: the thread's worktree, else its project's root. + fn details_cwd(&self) -> Option { + let shell = self.shell()?; + let row = self + .selected_thread + .as_deref() + .and_then(|id| shell.rows.iter().find(|row| row.id == id)); + if let Some(path) = row.and_then(|row| row.worktree_path.clone()) { + return Some(path); + } + let entry = match row { + Some(row) => shell + .project_entries + .iter() + .find(|entry| entry.contains(&row.project_id)), + None if self.selected_thread.is_some() => None, + None => active_project(self.selected_project.as_deref(), shell), + }?; + (!entry.scratch && !entry.root.is_empty()).then(|| entry.root.clone()) + } + + fn toggle_details(&mut self, cx: &mut Context) { + let Some(key) = self.details_key() else { + return; + }; + if !self.details_closed.remove(&key) { + self.details_closed.insert(key); + } + self.editor_menu_open = false; + self.sync_vcs(); + cx.notify(); + } + + /// Watches the status of the card's directory while the card is open, and only then. + fn sync_vcs(&mut self) { + let wanted = self.details_open().then(|| self.details_cwd()).flatten(); + if wanted == self.vcs_watching { + return; + } + self.vcs_watching = wanted.clone(); + if self.actions.try_send(Action::WatchVcs(wanted)).is_err() { + eprintln!("[ui] could not ask for the version control status"); + } + } + + fn installed_editors(&self) -> Vec<&'static str> { + self.providers.as_ref().map_or_else(Vec::new, |catalog| { + details::installed_editors(catalog.available_editors()) + }) + } + + fn preferred_editor(&self) -> Option<&'static str> { + details::preferred_editor(self.editor_pick.as_deref(), &self.installed_editors()) + } + + fn open_in_editor(&mut self, editor: &'static str, cx: &mut Context) { + let Some(cwd) = self.details_cwd() else { + return; + }; + self.editor_pick = Some(editor.to_owned()); + let action = Action::OpenInEditor { + cwd, + editor: editor.to_owned(), + }; + if self.actions.try_send(action).is_err() { + eprintln!("[ui] could not ask to open the editor"); + } + cx.notify(); + } + + /// The web's `editor.openFavorite`, live while the card's picker is. + fn open_favorite_editor(&mut self, cx: &mut Context) { + if !self.details_open() { + return; + } + if let Some(editor) = self.preferred_editor() { + self.open_in_editor(editor, cx); + } + } + fn shell(&self) -> Option<&ShellView> { match &self.status { Status::Connected { shell, .. } => shell.as_ref(), @@ -455,10 +585,177 @@ impl MainView { .border_color(border) .when(self.shell().is_some(), |row| { row.child(self.breadcrumb(active, title, cx)) + .child(div().flex_1()) + .when(self.details_cwd().is_some(), |row| { + row.child(self.details_toggle(cx)) + }) }), ) } + /// The web's `PanelLayoutControls` thread details toggle, pressed while the card is open. + fn details_toggle(&self, cx: &mut Context) -> impl IntoElement { + let pressed = self.details_open(); + let foreground = cx.theme().foreground; + header_icon_button( + "toggle-details", + Icons::SquareMenu, + format!("Toggle thread details ({})", details::TOGGLE_SHORTCUT).into(), + cx, + ) + .when(pressed, move |button| { + button.bg(foreground.opacity(0.06)).text_color(foreground) + }) + .on_click(cx.listener(|this, _, _, cx| this.toggle_details(cx))) + } + + /// The web's `ThreadDetailsCard`: Open in, then the branch and its changes. Environment, + /// scripts and git actions are not in this client yet. + fn details_card(&self, available: f32, cx: &mut Context) -> Option { + if !self.details_open() { + return None; + } + let cwd = self.details_cwd()?; + let max_height = details::card_height(self.main_width.get(), available)?; + let status = self.vcs.get(&cwd).and_then(Option::as_ref); + let editors = self.installed_editors(); + let has_run = self.selected_thread.as_deref().is_some_and(|id| { + self.shell() + .is_some_and(|shell| shell.rows.iter().any(|row| row.id == id && row.has_run)) + }); + let inputs = Inputs { + has_cwd: true, + is_repo: status.is_some_and(|status| status.local.is_repo), + has_editors: !editors.is_empty(), + quiet_empty_draft: details::quiet_empty_draft(has_run, status), + }; + let density = details::density_for(max_height, inputs); + let show = Show::resolve(density, inputs); + if show.is_empty() { + return None; + } + let theme = cx.theme(); + let (border, divider) = (gpui::white().opacity(0.05), theme.border); + let mut sections: Vec = Vec::new(); + if show.open_in { + sections.push(self.open_in_row(&editors, cx).into_any_element()); + } + if show.branch || show.changes { + let mut rows = v_flex(); + if let Some(status) = status.filter(|_| show.branch) { + let label = status + .local + .ref_name + .clone() + .unwrap_or_else(|| "Select ref".to_owned()); + rows = rows.child(detail_row(Icons::GitBranch, label, None, cx)); + } + if let Some(status) = status.filter(|_| show.changes) { + let totals = status.changes_totals(); + let counts = h_flex() + .gap_1() + .child(diff_count( + '+', + totals.insertions, + gpui::rgb(0x22c55e).into(), + cx, + )) + .child(diff_count( + '-', + totals.deletions, + gpui::rgb(0xef4444).into(), + cx, + )); + rows = rows.child(detail_row( + Icons::FileDiff, + "Changes".to_owned(), + Some(counts.into_any_element()), + cx, + )); + } + sections.push(rows.into_any_element()); + } + let last = sections.len().saturating_sub(1); + let body = sections.into_iter().enumerate().map(|(index, section)| { + v_flex() + .pt_2() + .pb(px(10.)) + .px_2() + .when(density == Density::Full && index < last, |wrap| { + wrap.border_b_1().border_color(divider) + }) + .child(section) + }); + Some( + div() + .id("thread-details-card") + .absolute() + .top(px(details::CARD_GAP)) + .right(px(details::CARD_GAP)) + .w(px(details::CARD_WIDTH)) + .max_h(px(max_height)) + .overflow_y_scroll() + .rounded(px(16.)) + .border_1() + .border_color(border) + .bg(cx.theme().secondary.opacity(0.8)) + .shadow_lg() + .children(body) + .into_any_element(), + ) + } + + /// The web's `OpenInPicker`: the preferred editor, and a menu of the installed ones. + fn open_in_row(&self, editors: &[&'static str], cx: &mut Context) -> impl IntoElement { + let preferred = self.preferred_editor(); + let label = preferred.map_or("Open in", |id| details::editor_label(id)); + let (foreground, hover) = (cx.theme().foreground, cx.theme().list_hover); + let view = cx.entity(); + let menu = self.editor_menu.clone(); + menu.update(cx, |menu, _| { + menu.editors = editors.to_vec(); + menu.preferred = preferred; + }); + let content_menu = menu.clone(); + let popover = Popover::new("editor-menu") + .anchor(Anchor::TopRight) + .open(self.editor_menu_open) + .p_0() + .on_open_change(move |open, _, cx| { + view.update(cx, |this, cx| { + this.editor_menu_open = *open; + cx.notify(); + }); + }) + .trigger(EditorTrigger { open: false }) + .content(move |_, _, _| content_menu.clone()); + h_flex() + .gap_1() + .child( + h_flex() + .id("open-in-editor") + .flex_1() + .min_w_0() + .h_8() + .px_2() + .gap_2() + .rounded(px(6.)) + .cursor_pointer() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .hover(move |style| style.bg(hover)) + .when(preferred.is_none(), |row| row.opacity(0.5)) + .child(Icon::new(Icons::SquareArrowOutUpRight).size_4()) + .child(div().truncate().child(match preferred { + Some(_) => format!("Open in {label}"), + None => label.to_owned(), + })) + .on_click(cx.listener(|this, _, _, cx| this.open_favorite_editor(cx))), + ) + .child(popover) + } + /// The web's `SidebarChrome`: toggle, then the brand and history while the sidebar is shown. fn sidebar_chrome(&self, open: bool, width: f32, cx: &mut Context) -> impl IntoElement { let backdrop = open && STAGE_BACKDROP; @@ -957,7 +1254,25 @@ impl MainView { ) } + /// The main content, with the thread details card floating at its top right when it fits. fn main_area(&self, viewport_height: f32, cx: &mut Context) -> AnyElement { + let content = self.main_content(viewport_height, cx); + let card = self.details_card(viewport_height - TOPBAR_HEIGHT, cx); + div() + .relative() + .flex() + .flex_1() + .min_w_0() + .h_full() + .when(card.is_some(), |area| { + area.pr(px(details::CARD_WIDTH + details::CARD_GAP + 32.)) + }) + .child(content) + .children(card) + .into_any_element() + } + + fn main_content(&self, viewport_height: f32, cx: &mut Context) -> AnyElement { let Some(shell) = self.shell() else { return centered(cx) .text_xl() @@ -1813,6 +2128,129 @@ impl RenderOnce for PermissionsTrigger { } } +/// The details card's chevron, as a popover trigger. +#[derive(IntoElement)] +struct EditorTrigger { + open: bool, +} + +impl Selectable for EditorTrigger { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } + + fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + fn is_open(&self) -> bool { + self.open + } +} + +impl RenderOnce for EditorTrigger { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let foreground = cx.theme().foreground; + ghost_icon_button( + "editor-menu-trigger", + Icon::new(Icons::ChevronDown).size_4(), + "Choose editor".into(), + true, + cx, + ) + .when(self.open, move |button| { + button.bg(foreground.opacity(0.06)).text_color(foreground) + }) + } +} + +/// The installed editors; clicking one only makes it the preferred one, as on the web. +struct EditorMenu { + view: gpui::WeakEntity, + editors: Vec<&'static str>, + preferred: Option<&'static str>, +} + +impl Render for EditorMenu { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let (foreground, muted, hover) = (theme.foreground, theme.muted_foreground, theme.accent); + let preferred = self.preferred; + v_flex() + .w(px(220.)) + .p_1() + .children(self.editors.iter().map(|&id| { + h_flex() + .id(id) + .h_8() + .px_2() + .gap_2() + .rounded(px(6.)) + .cursor_pointer() + .text_sm() + .text_color(foreground) + .hover(move |style| style.bg(hover)) + .child(div().flex_1().truncate().child(details::editor_label(id))) + .when(preferred == Some(id), |row| { + row.child( + div() + .text_xs() + .text_color(muted) + .child(details::OPEN_FAVORITE_SHORTCUT), + ) + }) + .on_click(cx.listener(move |this, _, _, cx| { + this.view + .update(cx, |view, cx| { + view.editor_pick = Some(id.to_owned()); + view.editor_menu_open = false; + cx.notify(); + }) + .ok(); + cx.notify(); + })) + })) + } +} + +/// A `h-8` row of the card: icon, label, and a trailing value. +fn detail_row( + icon: Icons, + label: String, + trailing: Option, + cx: &App, +) -> impl IntoElement { + let theme = cx.theme(); + h_flex() + .h_8() + .px_2() + .gap_2() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(theme.foreground) + .child(Icon::new(icon).size_4().text_color(theme.muted_foreground)) + .child(div().flex_1().min_w_0().truncate().child(label)) + .children(trailing) +} + +/// `+N` or `-N`, tinted while non-zero. +fn diff_count(sign: char, count: u64, tint: Hsla, cx: &App) -> impl IntoElement { + let color = if count == 0 { + cx.theme().muted_foreground + } else { + tint + }; + div() + .text_xs() + .text_color(color) + .child(format!("{sign}{}", details::format_diff_count(count))) +} + /// The web's `ComposerPermissionsSection`: four icon segments, and the shown mode named below. struct PermissionsPanel { view: gpui::WeakEntity, @@ -3071,6 +3509,8 @@ impl Render for MainView { let docked = self.sidebar_open && !narrow; let sidebar_width = clamp_sidebar_width(self.sidebar_width, self.brand_width.get(), width); let overlay = narrow && self.sidebar_overlay_open; + self.main_width + .set(width - if docked { sidebar_width } else { 0. }); v_flex() .relative() .size_full() @@ -3084,6 +3524,10 @@ impl Render for MainView { cx.listener(|this, _: &DismissSidebar, _, cx| this.close_sidebar_overlay(cx)), ) .on_action(cx.listener(|this, _: &OpenPermissions, _, cx| this.open_permissions(cx))) + .on_action(cx.listener(|this, _: &ToggleDetails, _, cx| this.toggle_details(cx))) + .on_action( + cx.listener(|this, _: &OpenFavoriteEditor, _, cx| this.open_favorite_editor(cx)), + ) .on_action(cx.listener(|this, _: &GoBack, _, cx| this.go_back(cx))) .on_action(cx.listener(|this, _: &GoForward, _, cx| this.go_forward(cx))) .when(docked, |root| { @@ -3170,6 +3614,8 @@ mod tests { instance_history: Vec::new(), pr_lines: Vec::new(), last_error: None, + worktree_path: None, + has_run: false, } } From a50bf54742bac6b7fb1f726153d7a1b5c3c736d7 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 12:57:51 -0500 Subject: [PATCH 28/82] feat(desktop-gpui): terminal drawer runs a shell in the thread's workspace --- apps/desktop-gpui/Cargo.lock | 97 +++++ apps/desktop-gpui/Cargo.toml | 1 + apps/desktop-gpui/src/assets.rs | 2 + apps/desktop-gpui/src/connection.rs | 139 +++++- apps/desktop-gpui/src/main.rs | 4 + apps/desktop-gpui/src/rpc.rs | 37 ++ apps/desktop-gpui/src/status.rs | 7 + apps/desktop-gpui/src/terminal.rs | 518 +++++++++++++++++++++++ apps/desktop-gpui/src/terminal_screen.rs | 473 +++++++++++++++++++++ apps/desktop-gpui/src/terminal_view.rs | 352 +++++++++++++++ apps/desktop-gpui/src/ui.rs | 224 +++++++++- 11 files changed, 1841 insertions(+), 13 deletions(-) create mode 100644 apps/desktop-gpui/src/terminal.rs create mode 100644 apps/desktop-gpui/src/terminal_screen.rs create mode 100644 apps/desktop-gpui/src/terminal_view.rs diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index 1fd614b4f8eb..2b763c33080d 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -86,6 +86,31 @@ dependencies = [ "memchr", ] +[[package]] +name = "alacritty_terminal" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" +dependencies = [ + "base64 0.22.1", + "bitflags 2.13.2", + "home", + "libc", + "log", + "miow", + "parking_lot", + "piper", + "polling", + "regex-automata", + "rustix", + "rustix-openpty", + "serde", + "signal-hook", + "unicode-width", + "vte", + "windows-sys 0.59.0", +] + [[package]] name = "aligned" version = "0.4.3" @@ -525,6 +550,9 @@ name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] [[package]] name = "bitstream-io" @@ -1137,6 +1165,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + [[package]] name = "data-encoding" version = "2.11.1" @@ -2754,6 +2788,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 = "html5ever" version = "0.27.0" @@ -3722,6 +3765,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" @@ -5508,6 +5560,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", +] + [[package]] name = "rustls" version = "0.23.46" @@ -5990,6 +6053,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "signal-hook" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f7447afd772ca79ad17ae0a3972a6b0df0cb4b80990fd82707593e9e012a74" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -6393,6 +6466,7 @@ name = "t3-desktop-gpui" version = "0.1.0" dependencies = [ "aes", + "alacritty_terminal", "async-channel", "cbc", "futures-util", @@ -7199,6 +7273,20 @@ 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.2", + "cursor-icon", + "log", + "memchr", + "serde", +] + [[package]] name = "waker-fn" version = "1.2.0" @@ -7949,6 +8037,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index 89688ba47d58..4a271b728869 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -7,6 +7,7 @@ publish = false [dependencies] aes = "0.8.4" +alacritty_terminal = "=0.26.0" async-channel = "2.5.0" cbc = { version = "0.1.2", features = ["alloc"] } futures-util = "0.3.34" diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 10d07ccbd1c9..574c92ffc8f0 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -42,6 +42,7 @@ gpui_kit_assets::icon_assets!( Layers, ArrowRightLeft, SquareMenu, + PanelBottom, ChevronDown, SquareArrowOutUpRight, FileDiff @@ -190,6 +191,7 @@ mod tests { for icon in [ IconName::Paperclip, IconName::SquareMenu, + IconName::PanelBottom, IconName::ChevronDown, IconName::SquareArrowOutUpRight, IconName::FileDiff, diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 16ad89d5ea61..0269e021f536 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -7,11 +7,13 @@ use crate::projects::MachineKind; use crate::providers::Catalog; use crate::rpc::{ Event, PING_FRAME, ProjectsMutate, ServerGetConfig, Session, ShellOpenInEditor, SubscribeShell, - SubscribeThread, SubscribeVcsStatus, VcsListRefs, + SubscribeThread, SubscribeVcsStatus, TerminalAttach, TerminalClose, TerminalResize, + TerminalWrite, VcsListRefs, }; use crate::shell::ShellState; use crate::status::{Status, StatusSender, publish}; use crate::strip::{self, Branch}; +use crate::terminal::{self, Grid, Target}; use crate::thread::ThreadState; use crate::wire::{ ProjectMutation, ProjectMutationType, ShellItem, SubscribeShellPayload, SubscribeThreadPayload, @@ -59,6 +61,20 @@ pub enum Action { cwd: String, editor: String, }, + /// The terminal the drawer shows; `None` detaches, and the shell keeps running. + AttachTerminal(Option), + TerminalWrite { + thread_id: String, + data: String, + }, + TerminalResize { + thread_id: String, + grid: Grid, + }, + /// Ends the thread's shell. + TerminalClose { + thread_id: String, + }, } pub type ActionSender = async_channel::Sender; @@ -73,6 +89,10 @@ impl Action { Self::SelectThread(_) => "open the thread", Self::WatchVcs(_) => "watch the version control status", Self::OpenInEditor { .. } => "open in the editor", + Self::AttachTerminal(_) => "attach the terminal", + Self::TerminalWrite { .. } => "write to the terminal", + Self::TerminalResize { .. } => "resize the terminal", + Self::TerminalClose { .. } => "close the terminal", } } } @@ -180,6 +200,7 @@ pub async fn run( // Outlives a socket: a reconnect subscribes to the thread again. let mut selected: Option = None; let mut watched: Option = None; + let mut terminal: Option = None; let mut backoff = Backoff::new(); loop { publish(status, Status::Connecting); @@ -199,7 +220,7 @@ pub async fn run( socket, environment, actions, - (&mut selected, &mut watched), + (&mut selected, &mut watched, &mut terminal), status, ) .await @@ -269,7 +290,11 @@ async fn serve( socket: Socket, environment: &Environment, actions: &ActionReceiver, - (selected, watched): (&mut Option, &mut Option), + (selected, watched, wanted_terminal): ( + &mut Option, + &mut Option, + &mut Option, + ), status: &StatusSender, ) -> String { let (mut sink, mut stream) = socket.split(); @@ -297,6 +322,7 @@ async fn serve( match action { Action::SelectThread(id) => *selected = id, Action::WatchVcs(cwd) => *watched = cwd, + Action::AttachTerminal(target) => *wanted_terminal = target, _ => {} } } @@ -314,6 +340,13 @@ async fn serve( { return error; } + let mut terminals = Terminals::default(); + if let Err(error) = terminals + .select(wanted_terminal.as_ref(), &mut session, &mut sink) + .await + { + return error; + } let mut pending: HashMap = HashMap::new(); // Branch lookups by request id, with the workspace root each was for. let mut branches: HashMap = HashMap::new(); @@ -350,6 +383,25 @@ async fn serve( } continue; } + Action::AttachTerminal(target) => { + *wanted_terminal = target.clone(); + if let Err(error) = terminals + .select(target.as_ref(), &mut session, &mut sink) + .await + { + return error; + } + continue; + } + Action::TerminalWrite { thread_id, data } => session + .request::(&terminal::write_payload(thread_id, data)) + .map(|(id, frame)| (id, frame, None)), + Action::TerminalResize { thread_id, grid } => session + .request::(&terminal::resize_payload(thread_id, *grid)) + .map(|(id, frame)| (id, frame, None)), + Action::TerminalClose { thread_id } => session + .request::(&terminal::close_payload(thread_id)) + .map(|(id, frame)| (id, frame, None)), Action::OpenInEditor { cwd, editor } => session .request::(&serde_json::json!({ "cwd": cwd, @@ -404,6 +456,9 @@ async fn serve( let Some(event) = vcs.route(event, status) else { continue; }; + let Some(event) = terminals.route(event, status) else { + continue; + }; if config_request.as_ref().is_some_and(|id| owns(&event, id)) { config_request = None; publish_catalog(event, status); @@ -621,6 +676,84 @@ impl VcsWatch { } } +/// The open terminal attachment, and the interrupted ones whose last frames still arrive. +#[derive(Default)] +struct Terminals { + open: Option, + stale: HashSet, +} + +struct OpenTerminal { + target: Target, + request_id: String, +} + +impl Terminals { + /// Switches the attachment to `wanted`, interrupting the previous one. Detaching leaves the + /// shell running on the server; attaching to a target again replays its history. + async fn select( + &mut self, + wanted: Option<&Target>, + session: &mut Session, + sink: &mut Sink, + ) -> Result<(), String> { + if self.open.as_ref().map(|open| &open.target) == wanted { + return Ok(()); + } + if let Some(open) = self.open.take() { + let frame = session.interrupt(&open.request_id); + self.stale.insert(open.request_id); + sink.send(Message::text(frame)) + .await + .map_err(|error| error.to_string())?; + } + let Some(target) = wanted else { + return Ok(()); + }; + let (request_id, frame) = session.request::(&target.attach_payload())?; + self.open = Some(OpenTerminal { + target: target.clone(), + request_id, + }); + sink.send(Message::text(frame)) + .await + .map_err(|error| error.to_string()) + } + + /// Consumes the events of the attach request, publishing each; hands back the rest. + fn route(&mut self, event: Event, status: &StatusSender) -> Option { + let id = match &event { + Event::Item { request_id, .. } + | Event::Done { request_id, .. } + | Event::Failed { request_id, .. } => request_id.clone(), + _ => return Some(event), + }; + if self.stale.contains(&id) { + if !matches!(event, Event::Item { .. }) { + self.stale.remove(&id); + } + return None; + } + let Some(open) = self.open.as_ref().filter(|open| open.request_id == id) else { + return Some(event); + }; + let thread_id = open.target.thread_id.clone(); + let event = match event { + Event::Item { value, .. } => terminal::decode_event(&value)?, + Event::Failed { message, .. } => { + self.open = None; + terminal::Event::Error(message) + } + _ => { + self.open = None; + terminal::Event::Closed + } + }; + publish(status, Status::Terminal { thread_id, event }); + None + } +} + fn command_id() -> String { let mut bytes = [0u8; 8]; // A failed read leaves zeros, and the server rejects a repeated command id rather than applying it twice. diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index e18576232a97..58f2a00b3c44 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -15,6 +15,9 @@ mod server; mod shell; mod status; mod strip; +mod terminal; +mod terminal_screen; +mod terminal_view; mod thread; mod thread_card; mod ui; @@ -98,6 +101,7 @@ fn main() { KeyBinding::new("escape", ui::DismissSidebar, Some(ui::OVERLAY_CONTEXT)), KeyBinding::new("shift-cmd-a", ui::OpenPermissions, None), KeyBinding::new("shift-cmd-b", ui::ToggleDetails, None), + KeyBinding::new("cmd-j", ui::ToggleTerminal, None), KeyBinding::new("cmd-o", ui::OpenFavoriteEditor, None), KeyBinding::new("cmd-[", ui::GoBack, None), KeyBinding::new("cmd-]", ui::GoForward, None), diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs index 0a0bde56b852..a0feafedf4d9 100644 --- a/apps/desktop-gpui/src/rpc.rs +++ b/apps/desktop-gpui/src/rpc.rs @@ -81,6 +81,43 @@ impl Method for SubscribeVcsStatus { type Success = Value; } +/// `terminal.attach`, a stream that opens the session when the server has none and then carries +/// its snapshot and output. The wire types are not generated; `terminal::decode_event` reads it. +pub struct TerminalAttach; + +impl Method for TerminalAttach { + const TAG: &'static str = "terminal.attach"; + type Payload = Value; + type Success = Value; +} + +/// `terminal.write`, a unary call that types into the shell. +pub struct TerminalWrite; + +impl Method for TerminalWrite { + const TAG: &'static str = "terminal.write"; + type Payload = Value; + type Success = Value; +} + +/// `terminal.resize`, a unary call that sets the session's shared grid. +pub struct TerminalResize; + +impl Method for TerminalResize { + const TAG: &'static str = "terminal.resize"; + type Payload = Value; + type Success = Value; +} + +/// `terminal.close`, a unary call that ends the session. +pub struct TerminalClose; + +impl Method for TerminalClose { + const TAG: &'static str = "terminal.close"; + type Payload = Value; + type Success = Value; +} + /// Request ids are bigints on the wire, so they travel as strings. fn id_text<'de, D: Deserializer<'de>>(deserializer: D) -> Result { match Value::deserialize(deserializer)? { diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index 05033fab57e3..8f7f1b8137ff 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -2,6 +2,7 @@ use crate::details::VcsStatus; use crate::projects::{EnvironmentProjects, MachineKind, ProjectEntry}; use crate::providers::Catalog; use crate::strip::Branch; +use crate::terminal; use crate::thread::Entry; use crate::thread_card::PrLine; use crate::wire::generated::{ThreadsEntryLastErrorClass, ThreadsEntryStatus}; @@ -42,6 +43,11 @@ pub enum Status { cwd: String, status: Option, }, + /// One element of the terminal drawer's attach stream, for the thread it belongs to. + Terminal { + thread_id: String, + event: terminal::Event, + }, } /// Where the web sidebar lists a thread. @@ -189,6 +195,7 @@ impl fmt::Display for Status { Self::Thread { id, .. } => write!(f, "Thread {id}"), Self::Branch { root, .. } => write!(f, "Branch of {root}"), Self::Vcs { cwd, .. } => write!(f, "Status of {cwd}"), + Self::Terminal { thread_id, .. } => write!(f, "Terminal of {thread_id}"), } } } diff --git a/apps/desktop-gpui/src/terminal.rs b/apps/desktop-gpui/src/terminal.rs new file mode 100644 index 000000000000..37fd44ef6197 --- /dev/null +++ b/apps/desktop-gpui/src/terminal.rs @@ -0,0 +1,518 @@ +//! The terminal drawer's logic, ported from `ThreadTerminalDrawer.tsx` (height bounds), +//! `useThreadTerminalActions.ts` (what a launch carries) and `terminalUiStateStore.ts` (which +//! terminal a thread starts with), plus the wire shapes of `packages/contracts/src/terminal.ts` +//! and the keyboard encoding the web gets from its terminal surface. +//! The terminal RPC types are not in the generated wire types; the payloads and the +//! `TerminalAttachStreamEvent` union are written here against that file. + +use gpui::Keystroke; +use serde_json::{Value, json}; + +/// `DEFAULT_THREAD_TERMINAL_ID`: every thread starts with this one terminal. +pub const DEFAULT_TERMINAL_ID: &str = "term-1"; +/// The web's default binding for `terminal.toggle` on macOS. +pub const TOGGLE_SHORTCUT: &str = "\u{2318}J"; + +/// `DEFAULT_THREAD_TERMINAL_HEIGHT`, `MIN_DRAWER_HEIGHT` and `MAX_DRAWER_HEIGHT_RATIO`. +pub const DEFAULT_HEIGHT: f32 = 280.; +pub const MIN_HEIGHT: f32 = 180.; +const MAX_HEIGHT_RATIO: f32 = 0.75; +/// The drawer's grid before it is measured, and the contract's bounds for one. +pub const FALLBACK_GRID: Grid = Grid { cols: 80, rows: 24 }; +const MAX_COLS: usize = 1000; +const MAX_ROWS: usize = 500; + +/// `resolveTerminalDrawerMaxHeight`: three quarters of the window, and never more than the chat +/// column leaves once the header and the floating composer are taken out. +pub fn max_height(viewport: f32, chat_column: f32) -> f32 { + let window_cap = MIN_HEIGHT.max((viewport * MAX_HEIGHT_RATIO).floor()); + window_cap.min(chat_column.max(0.)) +} + +/// `clampDrawerHeight`. +pub fn clamp_height(height: f32, max: f32) -> f32 { + let safe = if height.is_finite() { + height + } else { + DEFAULT_HEIGHT + }; + safe.round().max(MIN_HEIGHT).min(max) +} + +/// A terminal's character grid. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Grid { + pub cols: usize, + pub rows: usize, +} + +impl Grid { + /// What fits `width` by `height` pixels at the given cell size, within the contract's bounds. + pub fn fit(width: f32, height: f32, cell_width: f32, line_height: f32) -> Option { + if cell_width <= 0. || line_height <= 0. { + return None; + } + let cols = (width / cell_width).floor(); + let rows = (height / line_height).floor(); + (cols >= 1. && rows >= 1.).then(|| Self { + cols: (cols as usize).min(MAX_COLS), + rows: (rows as usize).min(MAX_ROWS), + }) + } + + fn within_contract(cols: u64, rows: u64) -> Option { + let valid = (1..=MAX_COLS as u64).contains(&cols) && (1..=MAX_ROWS as u64).contains(&rows); + valid.then_some(Self { + cols: cols as usize, + rows: rows as usize, + }) + } +} + +/// Where a thread's terminal runs: its worktree, else its project's root +/// (`projectScriptCwd`), with the environment `projectScriptRuntimeEnv` adds. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Target { + pub thread_id: String, + pub project_root: String, + pub worktree_path: Option, +} + +impl Target { + pub fn cwd(&self) -> &str { + self.worktree_path.as_deref().unwrap_or(&self.project_root) + } + + /// `terminal.attach`, which opens the session when the server has none and otherwise joins it. + pub fn attach_payload(&self) -> Value { + let mut env = serde_json::Map::new(); + env.insert("T3CODE_PROJECT_ROOT".into(), json!(self.project_root)); + if let Some(path) = &self.worktree_path { + env.insert("T3CODE_WORKTREE_PATH".into(), json!(path)); + } + let mut payload = json!({ + "threadId": self.thread_id, + "terminalId": DEFAULT_TERMINAL_ID, + "cwd": self.cwd(), + "env": env, + }); + if let Some(path) = &self.worktree_path { + payload["worktreePath"] = json!(path); + } + payload + } +} + +pub fn write_payload(thread_id: &str, data: &str) -> Value { + json!({ "threadId": thread_id, "terminalId": DEFAULT_TERMINAL_ID, "data": data }) +} + +pub fn resize_payload(thread_id: &str, grid: Grid) -> Value { + json!({ + "threadId": thread_id, + "terminalId": DEFAULT_TERMINAL_ID, + "cols": grid.cols, + "rows": grid.rows, + }) +} + +/// Ends the session and its history, as `closeTerminalSession` does. +pub fn close_payload(thread_id: &str) -> Value { + json!({ "threadId": thread_id, "terminalId": DEFAULT_TERMINAL_ID, "deleteHistory": true }) +} + +/// One element of the `terminal.attach` stream (`TerminalAttachStreamEvent`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Event { + /// The session as the server holds it: its output so far replaces whatever was drawn. + Snapshot { + history: String, + grid: Option, + exited: bool, + }, + Output(String), + Cleared, + Restarted { + history: String, + grid: Option, + }, + /// The shared PTY grid changed. + Resized(Grid), + Exited, + Closed, + Error(String), +} + +fn grid_of(value: &Value) -> Option { + Grid::within_contract(value.get("cols")?.as_u64()?, value.get("rows")?.as_u64()?) +} + +fn text(value: &Value, key: &str) -> Option { + value.get(key)?.as_str().map(str::to_owned) +} + +/// Reads a stream element; `None` for the ones this client has nothing to do for (`activity` +/// without a grid) and for shapes it does not know. +pub fn decode_event(value: &Value) -> Option { + match value.get("type")?.as_str()? { + "snapshot" => { + let snapshot = value.get("snapshot")?; + Some(Event::Snapshot { + history: text(snapshot, "history")?, + grid: grid_of(snapshot), + exited: matches!( + snapshot.get("status").and_then(Value::as_str), + Some("exited" | "error") + ), + }) + } + "restarted" => { + let snapshot = value.get("snapshot")?; + Some(Event::Restarted { + history: text(snapshot, "history")?, + grid: grid_of(snapshot), + }) + } + "output" => Some(Event::Output(text(value, "data")?)), + "cleared" => Some(Event::Cleared), + "exited" => Some(Event::Exited), + "closed" => Some(Event::Closed), + "error" => Some(Event::Error(text(value, "message")?)), + "activity" => grid_of(value).map(Event::Resized), + _ => None, + } +} + +/// What the keyboard sends the shell: the bytes xterm would, for printable text, Enter, +/// Backspace, Tab, Escape, the arrows and navigation keys, function keys and Ctrl chords. +/// Command chords are the app's, so they send nothing. +pub fn encode_key(keystroke: &Keystroke, app_cursor: bool) -> Option { + let mods = keystroke.modifiers; + if mods.platform || mods.function { + return None; + } + let key = keystroke.key.as_str(); + // xterm's modifier parameter: 1 + shift + 2 * alt + 4 * ctrl. + let param = 1 + mods.shift as u8 + 2 * mods.alt as u8 + 4 * mods.control as u8; + let modified = param > 1; + let csi = |code: &str, final_byte: char| -> String { + if modified { + format!("\x1b[{code};{param}{final_byte}") + } else { + format!("\x1b[{}{final_byte}", if code == "1" { "" } else { code }) + } + }; + let cursor = |letter: char| -> String { + if modified { + format!("\x1b[1;{param}{letter}") + } else if app_cursor { + format!("\x1bO{letter}") + } else { + format!("\x1b[{letter}") + } + }; + let named = match key { + "enter" => Some(if mods.alt { "\x1b\r" } else { "\r" }.to_owned()), + "backspace" => Some(if mods.alt { "\x1b\x7f" } else { "\x7f" }.to_owned()), + "tab" if mods.shift => Some("\x1b[Z".to_owned()), + "tab" => Some("\t".to_owned()), + "escape" => Some("\x1b".to_owned()), + "up" => Some(cursor('A')), + "down" => Some(cursor('B')), + "right" => Some(cursor('C')), + "left" => Some(cursor('D')), + "home" => Some(cursor('H')), + "end" => Some(cursor('F')), + "insert" => Some(csi("2", '~')), + "delete" => Some(csi("3", '~')), + "pageup" => Some(csi("5", '~')), + "pagedown" => Some(csi("6", '~')), + "f1" => Some(function_key(1, param)), + "f2" => Some(function_key(2, param)), + "f3" => Some(function_key(3, param)), + "f4" => Some(function_key(4, param)), + "f5" => Some(csi("15", '~')), + "f6" => Some(csi("17", '~')), + "f7" => Some(csi("18", '~')), + "f8" => Some(csi("19", '~')), + "f9" => Some(csi("20", '~')), + "f10" => Some(csi("21", '~')), + "f11" => Some(csi("23", '~')), + "f12" => Some(csi("24", '~')), + _ => None, + }; + if named.is_some() { + return named; + } + if mods.control { + let byte = control_byte(key)?; + let mut sent = String::new(); + if mods.alt { + sent.push('\x1b'); + } + sent.push(byte as char); + return Some(sent); + } + if key == "space" { + return Some(if mods.alt { "\x1b " } else { " " }.to_owned()); + } + // `key_char` is the typed character, composed by Option and the layout. + keystroke + .key_char + .as_deref() + .filter(|typed| !typed.is_empty() && !typed.chars().any(char::is_control)) + .map(str::to_owned) +} + +fn function_key(number: u8, param: u8) -> String { + let letter = (b'O' + number) as char; + if param > 1 { + format!("\x1b[1;{param}{letter}") + } else { + format!("\x1bO{letter}") + } +} + +/// The C0 byte a Ctrl chord types, `Ctrl-A` as 0x01 through `Ctrl-_` as 0x1f. +fn control_byte(key: &str) -> Option { + let mut chars = key.chars(); + let (first, None) = (chars.next()?, chars.next()) else { + return (key == "space").then_some(0); + }; + match first { + 'a'..='z' => Some(first as u8 - b'a' + 1), + '@' | ' ' | '2' => Some(0), + '[' => Some(0x1b), + '\\' => Some(0x1c), + ']' => Some(0x1d), + '^' | '6' => Some(0x1e), + '_' | '-' => Some(0x1f), + '?' => Some(0x7f), + _ => None, + } +} + +/// Pasted text, which the shell takes as one unit when it asked for bracketed paste. +pub fn paste_text(text: &str, bracketed: bool) -> String { + if bracketed { + // A pasted escape would end the bracket early. + format!("\x1b[200~{}\x1b[201~", text.replace('\x1b', "")) + } else { + text.replace("\r\n", "\r").replace('\n', "\r") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A parsed keystroke with the `key_char` a real key event carries for a printable key. + fn typed(text: &'static str) -> Keystroke { + let mut keystroke = Keystroke::parse(text).expect("keystroke"); + if keystroke.key.chars().count() == 1 { + keystroke.key_char = Some(if keystroke.modifiers.shift { + keystroke.key.to_uppercase() + } else { + keystroke.key.clone() + }); + } + keystroke + } + + fn keys(text: &'static str) -> Option { + encode_key(&typed(text), false) + } + + #[test] + fn height_follows_the_webs_bounds() { + assert_eq!(max_height(1000., 900.), 750.); + assert_eq!(max_height(1000., 400.), 400.); + assert_eq!(max_height(100., 900.), MIN_HEIGHT); + assert_eq!(clamp_height(50., 750.), MIN_HEIGHT); + assert_eq!(clamp_height(900., 750.), 750.); + assert_eq!(clamp_height(300.4, 750.), 300.); + assert_eq!(clamp_height(f32::NAN, 750.), DEFAULT_HEIGHT); + // A chat column too short for the minimum wins, as on the web. + assert_eq!(clamp_height(280., 120.), 120.); + } + + #[test] + fn the_grid_is_what_fits_within_the_contract() { + assert_eq!( + Grid::fit(805., 330., 8., 16.), + Some(Grid { + cols: 100, + rows: 20 + }) + ); + assert_eq!(Grid::fit(4., 330., 8., 16.), None); + assert_eq!(Grid::fit(80., 330., 0., 16.), None); + assert_eq!( + Grid::fit(100_000., 100_000., 8., 16.), + Some(Grid { + cols: 1000, + rows: 500 + }) + ); + } + + #[test] + fn attach_runs_in_the_worktree_else_the_project_root() { + let mut target = Target { + thread_id: "t1".into(), + project_root: "/work/app".into(), + worktree_path: None, + }; + assert_eq!( + target.attach_payload(), + json!({ + "threadId": "t1", + "terminalId": "term-1", + "cwd": "/work/app", + "env": { "T3CODE_PROJECT_ROOT": "/work/app" }, + }) + ); + target.worktree_path = Some("/work/app-wt".into()); + assert_eq!( + target.attach_payload(), + json!({ + "threadId": "t1", + "terminalId": "term-1", + "cwd": "/work/app-wt", + "worktreePath": "/work/app-wt", + "env": { + "T3CODE_PROJECT_ROOT": "/work/app", + "T3CODE_WORKTREE_PATH": "/work/app-wt", + }, + }) + ); + } + + #[test] + fn unary_payloads_name_the_default_terminal() { + assert_eq!( + write_payload("t1", "ls\r"), + json!({"threadId": "t1", "terminalId": "term-1", "data": "ls\r"}) + ); + assert_eq!( + resize_payload("t1", Grid { cols: 90, rows: 12 }), + json!({"threadId": "t1", "terminalId": "term-1", "cols": 90, "rows": 12}) + ); + assert_eq!( + close_payload("t1"), + json!({"threadId": "t1", "terminalId": "term-1", "deleteHistory": true}) + ); + } + + #[test] + fn stream_events_decode_from_the_contract() { + let snapshot = json!({ + "type": "snapshot", + "snapshot": { + "threadId": "t1", "terminalId": "term-1", "cwd": "/w", "worktreePath": null, + "status": "running", "pid": 7, "history": "$ ", "exitCode": null, + "exitSignal": null, "label": "zsh", "updatedAt": "now", + "cols": 100, "rows": 30, + }, + }); + assert_eq!( + decode_event(&snapshot), + Some(Event::Snapshot { + history: "$ ".into(), + grid: Some(Grid { + cols: 100, + rows: 30 + }), + exited: false + }) + ); + let old_server = + json!({"type": "snapshot", "snapshot": {"history": "", "status": "exited"}}); + assert_eq!( + decode_event(&old_server), + Some(Event::Snapshot { + history: String::new(), + grid: None, + exited: true + }) + ); + assert_eq!( + decode_event(&json!({"type": "output", "data": "hi\r\n"})), + Some(Event::Output("hi\r\n".into())) + ); + assert_eq!( + decode_event(&json!({"type": "error", "message": "boom"})), + Some(Event::Error("boom".into())) + ); + assert_eq!( + decode_event(&json!({"type": "activity", "hasRunningSubprocess": true, "label": "x"})), + None + ); + assert_eq!( + decode_event(&json!({"type": "activity", "label": "x", "cols": 50, "rows": 9})), + Some(Event::Resized(Grid { cols: 50, rows: 9 })) + ); + assert_eq!( + decode_event(&json!({"type": "exited", "exitCode": 0})), + Some(Event::Exited) + ); + assert_eq!( + decode_event(&json!({"type": "closed"})), + Some(Event::Closed) + ); + assert_eq!(decode_event(&json!({"type": "mystery"})), None); + assert_eq!(decode_event(&json!({"type": "output"})), None); + } + + #[test] + fn printable_text_and_named_keys_encode_as_xterm_does() { + assert_eq!(keys("a").as_deref(), Some("a")); + assert_eq!(keys("shift-a").as_deref(), Some("A")); + assert_eq!(keys("enter").as_deref(), Some("\r")); + assert_eq!(keys("backspace").as_deref(), Some("\x7f")); + assert_eq!(keys("tab").as_deref(), Some("\t")); + assert_eq!(keys("shift-tab").as_deref(), Some("\x1b[Z")); + assert_eq!(keys("escape").as_deref(), Some("\x1b")); + assert_eq!(keys("space").as_deref(), Some(" ")); + assert_eq!(keys("up").as_deref(), Some("\x1b[A")); + assert_eq!(keys("left").as_deref(), Some("\x1b[D")); + assert_eq!(keys("delete").as_deref(), Some("\x1b[3~")); + assert_eq!(keys("pageup").as_deref(), Some("\x1b[5~")); + assert_eq!(keys("f1").as_deref(), Some("\x1bOP")); + assert_eq!(keys("f5").as_deref(), Some("\x1b[15~")); + assert_eq!(keys("ctrl-right").as_deref(), Some("\x1b[1;5C")); + assert_eq!(keys("alt-backspace").as_deref(), Some("\x1b\x7f")); + } + + #[test] + fn application_cursor_mode_changes_only_unmodified_arrows() { + let up = Keystroke::parse("up").expect("keystroke"); + assert_eq!(encode_key(&up, true).as_deref(), Some("\x1bOA")); + let ctrl_up = Keystroke::parse("ctrl-up").expect("keystroke"); + assert_eq!(encode_key(&ctrl_up, true).as_deref(), Some("\x1b[1;5A")); + } + + #[test] + fn control_chords_type_c0_bytes() { + assert_eq!(keys("ctrl-c").as_deref(), Some("\x03")); + assert_eq!(keys("ctrl-a").as_deref(), Some("\x01")); + assert_eq!(keys("ctrl-z").as_deref(), Some("\x1a")); + assert_eq!(keys("ctrl-[").as_deref(), Some("\x1b")); + assert_eq!(keys("ctrl-space").as_deref(), Some("\0")); + assert_eq!(keys("ctrl-alt-d").as_deref(), Some("\x1b\x04")); + } + + #[test] + fn command_chords_belong_to_the_app() { + assert_eq!(keys("cmd-c"), None); + assert_eq!(keys("cmd-j"), None); + assert_eq!(keys("shift"), None); + } + + #[test] + fn paste_brackets_when_the_shell_asked_for_it() { + assert_eq!(paste_text("a\nb", false), "a\rb"); + assert_eq!(paste_text("a\r\nb", false), "a\rb"); + assert_eq!(paste_text("a\x1b[201~b", true), "\x1b[200~a[201~b\x1b[201~"); + } +} diff --git a/apps/desktop-gpui/src/terminal_screen.rs b/apps/desktop-gpui/src/terminal_screen.rs new file mode 100644 index 000000000000..3ed2e492e872 --- /dev/null +++ b/apps/desktop-gpui/src/terminal_screen.rs @@ -0,0 +1,473 @@ +//! The terminal's screen: the server's output parsed into a character grid, and that grid read +//! back as runs of styled text. The web feeds the same bytes to xterm.js in +//! `ThreadTerminalDrawer.tsx`; this is the alacritty parser doing that job. + +use crate::terminal::Grid; +use alacritty_terminal::event::{Event, EventListener}; +use alacritty_terminal::grid::{Dimensions, Scroll}; +use alacritty_terminal::term::cell::Flags; +use alacritty_terminal::term::{Config, Term, TermMode}; +use alacritty_terminal::vte::ansi::{Color, NamedColor, Processor, Rgb}; +use std::cell::RefCell; +use std::rc::Rc; + +/// The web's `scrollback` option for the drawer's xterm. +const SCROLLBACK: usize = 5_000; + +/// The sixteen ANSI colors plus the default foreground and background. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Palette { + pub foreground: Rgb, + pub background: Rgb, + pub ansi: [Rgb; 16], +} + +const fn rgb(hex: u32) -> Rgb { + Rgb { + r: (hex >> 16) as u8, + g: (hex >> 8) as u8, + b: hex as u8, + } +} + +impl Palette { + /// The ANSI colors of a dark theme; the window's theme supplies the default pair. + pub const fn dark(foreground: Rgb, background: Rgb) -> Self { + Self { + foreground, + background, + ansi: [ + rgb(0x3b4252), + rgb(0xe06c75), + rgb(0x98c379), + rgb(0xe5c07b), + rgb(0x61afef), + rgb(0xc678dd), + rgb(0x56b6c2), + rgb(0xabb2bf), + rgb(0x5c6370), + rgb(0xff7b86), + rgb(0xb1e18b), + rgb(0xf0d197), + rgb(0x7bc0ff), + rgb(0xd88cf0), + rgb(0x6fd0dc), + rgb(0xffffff), + ], + } + } + + fn indexed(&self, index: usize) -> Rgb { + match index { + 0..=15 => self.ansi[index], + 16..=231 => { + let cube = index - 16; + let level = |step: usize| if step == 0 { 0 } else { (55 + 40 * step) as u8 }; + Rgb { + r: level(cube / 36), + g: level(cube / 6 % 6), + b: level(cube % 6), + } + } + _ => { + let gray = (8 + 10 * (index - 232)) as u8; + Rgb { + r: gray, + g: gray, + b: gray, + } + } + } + } + + fn resolve(&self, color: Color, overrides: &alacritty_terminal::term::color::Colors) -> Rgb { + let index = match color { + Color::Spec(rgb) => return rgb, + Color::Indexed(index) => index as usize, + Color::Named(name) => name as usize, + }; + if let Some(rgb) = overrides[index] { + return rgb; + } + match color { + Color::Indexed(index) => self.indexed(index as usize), + Color::Named(NamedColor::Background | NamedColor::Cursor) => self.background, + Color::Named(name) => { + let index = name as usize; + match index { + 0..=15 => self.ansi[index], + // DimBlack = 259 through DimWhite = 266, then the foreground pair. + 259..=266 => dim(self.ansi[index - 259]), + 268 => dim(self.foreground), + _ => self.foreground, + } + } + Color::Spec(rgb) => rgb, + } + } +} + +fn dim(color: Rgb) -> Rgb { + let scale = |value: u8| (value as u16 * 2 / 3) as u8; + Rgb { + r: scale(color.r), + g: scale(color.g), + b: scale(color.b), + } +} + +/// How one run of cells draws. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Style { + pub foreground: Rgb, + /// `None` is the default background, which the drawer's own fill already paints. + pub background: Option, + pub bold: bool, + pub italic: bool, + pub underline: bool, + pub strikethrough: bool, +} + +/// `len` bytes of a row's text drawn in `style`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Span { + pub len: usize, + pub style: Style, +} + +/// One screen row: always `cols` cells of text, so a column is a character offset. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Row { + pub text: String, + pub spans: Vec, +} + +/// What the shell asked the terminal to say back (cursor position reports, color queries). +#[derive(Clone, Default)] +struct Replies(Rc>>); + +impl EventListener for Replies { + fn send_event(&self, event: Event) { + if let Event::PtyWrite(text) = event { + self.0.borrow_mut().push(text); + } + } +} + +struct Size(Grid); + +impl Dimensions for Size { + fn total_lines(&self) -> usize { + self.0.rows + } + + fn screen_lines(&self) -> usize { + self.0.rows + } + + fn columns(&self) -> usize { + self.0.cols + } +} + +pub struct Screen { + term: Term, + parser: Processor, + replies: Replies, + grid: Grid, +} + +impl Screen { + pub fn new(grid: Grid) -> Self { + let replies = Replies::default(); + let config = Config { + scrolling_history: SCROLLBACK, + ..Config::default() + }; + Self { + term: Term::new(config, &Size(grid), replies.clone()), + parser: Processor::new(), + replies, + grid, + } + } + + #[cfg(test)] + fn grid(&self) -> Grid { + self.grid + } + + /// Parses output; the result is what the shell's terminal queries need written back. + pub fn feed(&mut self, data: &str) -> Vec { + self.parser.advance(&mut self.term, data.as_bytes()); + self.replies.0.take() + } + + /// Starts over from the server's copy of the session. + pub fn reset(&mut self, history: &str, grid: Grid) -> Vec { + *self = Self::new(grid); + self.feed(history) + } + + pub fn resize(&mut self, grid: Grid) { + if grid != self.grid { + self.grid = grid; + self.term.resize(Size(grid)); + } + } + + /// Scrolls the view into the history by `lines` (positive is up); output snaps it back. + pub fn scroll(&mut self, lines: i32) { + self.term.scroll_display(Scroll::Delta(lines)); + } + + pub fn snap_to_bottom(&mut self) { + self.term.scroll_display(Scroll::Bottom); + } + + pub fn application_cursor(&self) -> bool { + self.term.mode().contains(TermMode::APP_CURSOR) + } + + pub fn bracketed_paste(&self) -> bool { + self.term.mode().contains(TermMode::BRACKETED_PASTE) + } + + /// Whether a full-screen program owns the screen, where the wheel has no scrollback to move. + pub fn alternate_screen(&self) -> bool { + self.term.mode().contains(TermMode::ALT_SCREEN) + } + + /// The visible rows, and the cursor's row and column when it shows. + pub fn rows(&self, palette: &Palette) -> (Vec, Option<(usize, usize)>) { + let content = self.term.renderable_content(); + let offset = content.display_offset as i32; + let mut rows = vec![Row::default(); self.grid.rows]; + for cell in content.display_iter { + let flags = cell.cell.flags; + if flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { + continue; + } + let Some(row) = usize::try_from(cell.point.line.0 + offset) + .ok() + .and_then(|row| rows.get_mut(row)) + else { + continue; + }; + let mut foreground = palette.resolve(cell.cell.fg, content.colors); + let mut background = palette.resolve(cell.cell.bg, content.colors); + if flags.contains(Flags::INVERSE) { + std::mem::swap(&mut foreground, &mut background); + } + if flags.contains(Flags::DIM) { + foreground = dim(foreground); + } + if flags.contains(Flags::HIDDEN) { + foreground = background; + } + let style = Style { + foreground, + background: (background != palette.background).then_some(background), + bold: flags.contains(Flags::BOLD), + italic: flags.contains(Flags::ITALIC), + underline: flags.intersects(Flags::ALL_UNDERLINES), + strikethrough: flags.contains(Flags::STRIKEOUT), + }; + push(row, cell.cell.c, style); + } + let cursor = content.cursor; + let cursor = (cursor.shape != alacritty_terminal::vte::ansi::CursorShape::Hidden + && content.display_offset == 0) + .then_some((cursor.point.line.0, cursor.point.column.0)) + .and_then(|(line, column)| Some((usize::try_from(line).ok()?, column))); + (rows, cursor) + } + + #[cfg(test)] + fn text(&self) -> Vec { + let palette = Palette::dark(rgb(0xffffff), rgb(0)); + let (rows, _) = self.rows(&palette); + rows.into_iter() + .map(|row| row.text.trim_end().to_owned()) + .collect() + } +} + +/// The row's spans with the cell at `column` drawn as the cursor: `foreground` on `background`. +pub fn mark_cursor(row: &Row, column: usize, foreground: Rgb, background: Rgb) -> Vec { + let Some((start, c)) = row.text.char_indices().nth(column) else { + return row.spans.clone(); + }; + let end = start + c.len_utf8(); + let mut marked = Vec::with_capacity(row.spans.len() + 2); + let mut offset = 0; + for span in &row.spans { + let (from, to) = (offset, offset + span.len); + offset = to; + if to <= start || from >= end { + marked.push(span.clone()); + continue; + } + if from < start { + marked.push(Span { + len: start - from, + style: span.style, + }); + } + marked.push(Span { + len: end - start, + style: Style { + foreground, + background: Some(background), + ..span.style + }, + }); + if to > end { + marked.push(Span { + len: to - end, + style: span.style, + }); + } + } + marked +} + +fn push(row: &mut Row, c: char, style: Style) { + row.text.push(c); + let len = c.len_utf8(); + match row.spans.last_mut() { + Some(last) if last.style == style => last.len += len, + _ => row.spans.push(Span { len, style }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const GRID: Grid = Grid { cols: 12, rows: 4 }; + + fn palette() -> Palette { + Palette::dark(rgb(0xeeeeee), rgb(0x111111)) + } + + #[test] + fn output_lands_on_the_grid() { + let mut screen = Screen::new(GRID); + screen.feed("hello\r\nworld"); + assert_eq!(screen.text(), ["hello", "world", "", ""]); + screen.feed("\x1b[2J\x1b[Hagain"); + assert_eq!(screen.text(), ["again", "", "", ""]); + } + + #[test] + fn a_row_is_always_as_wide_as_the_grid() { + let screen = Screen::new(GRID); + let (rows, _) = screen.rows(&palette()); + assert_eq!(rows.len(), 4); + assert!(rows.iter().all(|row| row.text.chars().count() == 12)); + assert!(rows.iter().all(|row| row.spans.len() == 1)); + } + + #[test] + fn colors_and_attributes_split_the_runs() { + let mut screen = Screen::new(GRID); + screen.feed("a\x1b[1;31mb\x1b[0m\x1b[44mc\x1b[7md\x1b[0m"); + let (rows, _) = screen.rows(&palette()); + let spans = &rows[0].spans; + let palette = palette(); + assert_eq!(spans[0].len, 1); + assert_eq!(spans[0].style.foreground, palette.foreground); + assert!(!spans[0].style.bold); + assert_eq!(spans[1].style.foreground, palette.ansi[1]); + assert!(spans[1].style.bold); + assert_eq!(spans[2].style.background, Some(palette.ansi[4])); + // Inverse swaps the pair, so the default background becomes the text color. + assert_eq!(spans[3].style.foreground, palette.ansi[4]); + assert_eq!(spans[3].style.background, Some(palette.foreground)); + assert_eq!(spans.iter().map(|span| span.len).sum::(), 12); + } + + #[test] + fn extended_colors_resolve() { + let mut screen = Screen::new(GRID); + screen.feed("\x1b[38;5;196ma\x1b[38;5;240mb\x1b[38;2;1;2;3mc"); + let (rows, _) = screen.rows(&palette()); + let colors: Vec<_> = rows[0] + .spans + .iter() + .take(3) + .map(|span| span.style.foreground) + .collect(); + assert_eq!(colors[0], rgb(0xff0000)); + assert_eq!(colors[1], rgb(0x585858)); + assert_eq!(colors[2], rgb(0x010203)); + } + + #[test] + fn the_cursor_follows_output_and_hides_on_request() { + let mut screen = Screen::new(GRID); + screen.feed("ab\r\nc"); + assert_eq!(screen.rows(&palette()).1, Some((1, 1))); + screen.feed("\x1b[?25l"); + assert_eq!(screen.rows(&palette()).1, None); + } + + #[test] + fn the_cursor_cell_splits_its_run() { + let mut screen = Screen::new(GRID); + screen.feed("abcd"); + let palette = palette(); + let (rows, _) = screen.rows(&palette); + let marked = mark_cursor(&rows[0], 2, palette.background, palette.foreground); + let lens: Vec<_> = marked.iter().map(|span| span.len).collect(); + assert_eq!(lens, [2, 1, 9]); + assert_eq!(marked[1].style.background, Some(palette.foreground)); + assert_eq!(marked[0].style, rows[0].spans[0].style); + assert_eq!( + mark_cursor(&rows[0], 99, palette.background, palette.foreground), + rows[0].spans + ); + } + + #[test] + fn terminal_queries_come_back_as_replies() { + let mut screen = Screen::new(GRID); + assert_eq!(screen.feed("ab\x1b[6n"), ["\x1b[1;3R"]); + assert!(screen.feed("c").is_empty()); + } + + #[test] + fn modes_follow_the_programs_requests() { + let mut screen = Screen::new(GRID); + assert!(!screen.application_cursor() && !screen.bracketed_paste()); + screen.feed("\x1b[?1h\x1b[?2004h"); + assert!(screen.application_cursor() && screen.bracketed_paste()); + screen.feed("\x1b[?1049h"); + assert!(screen.alternate_screen()); + } + + #[test] + fn scrolling_reads_the_history() { + let mut screen = Screen::new(GRID); + screen.feed("1\r\n2\r\n3\r\n4\r\n5\r\n6"); + assert_eq!(screen.text(), ["3", "4", "5", "6"]); + screen.scroll(2); + assert_eq!(screen.text(), ["1", "2", "3", "4"]); + assert_eq!(screen.rows(&palette()).1, None); + screen.snap_to_bottom(); + assert_eq!(screen.text(), ["3", "4", "5", "6"]); + } + + #[test] + fn reset_replays_the_servers_history_and_resize_reflows() { + let mut screen = Screen::new(GRID); + screen.feed("stale"); + screen.reset("fresh\r\n", Grid { cols: 6, rows: 2 }); + assert_eq!(screen.text(), ["fresh", ""]); + assert_eq!(screen.grid(), Grid { cols: 6, rows: 2 }); + screen.resize(Grid { cols: 20, rows: 3 }); + assert_eq!(screen.text().len(), 3); + assert_eq!(screen.rows(&palette()).0[0].text.chars().count(), 20); + } +} diff --git a/apps/desktop-gpui/src/terminal_view.rs b/apps/desktop-gpui/src/terminal_view.rs new file mode 100644 index 000000000000..6f6563cbe511 --- /dev/null +++ b/apps/desktop-gpui/src/terminal_view.rs @@ -0,0 +1,352 @@ +//! The terminal drawer's surface, ported from `ThreadTerminalDrawer.tsx`: the shell's screen +//! drawn as text runs, with the keyboard, paste and wheel wired to the session. + +use crate::connection::{Action, ActionSender}; +use crate::terminal::{self, Event, FALLBACK_GRID, Grid}; +use crate::terminal_screen::{Palette, Row, Screen, Span, mark_cursor}; +use alacritty_terminal::vte::ansi::Rgb; +use gpui::{ + App, Bounds, Context, FocusHandle, Focusable, Font, FontStyle, FontWeight, Hsla, KeyDownEvent, + MouseButton, Pixels, Render, Rgba, ScrollEvent, StrikethroughStyle, StyledText, TextRun, + UnderlineStyle, Window, canvas, div, prelude::*, px, +}; +use gpui_component::ActiveTheme; +use std::cell::Cell; +use std::rc::Rc; + +/// The drawer's inner padding around the character grid. +const PADDING_X: f32 = 12.; +const PADDING_Y: f32 = 8.; +/// Line height as a multiple of the mono font size. +const LINE_HEIGHT_RATIO: f32 = 1.35; + +pub struct TerminalView { + thread_id: Option, + screen: Screen, + /// Whether the server's snapshot has landed; input and resizes wait for it. + ready: bool, + /// Whether the shell is gone, so there is nothing to type into. + ended: bool, + /// The grid last sent to the server for this attachment. + requested: Option, + focus: FocusHandle, + /// The grid area's bounds at the last paint, for fitting the character grid. + area: Rc>>, + /// Wheel movement short of a whole line. + scroll_remainder: f32, + actions: ActionSender, +} + +impl TerminalView { + pub fn new(actions: ActionSender, cx: &mut Context) -> Self { + Self { + thread_id: None, + screen: Screen::new(FALLBACK_GRID), + ready: false, + ended: false, + requested: None, + focus: cx.focus_handle(), + area: Rc::default(), + scroll_remainder: 0., + actions, + } + } + + pub fn focus(&self, window: &mut Window, cx: &mut App) { + self.focus.focus(window, cx); + } + + /// Points the surface at a thread's terminal; the server replays the session's history. + pub fn show(&mut self, thread_id: Option) { + if self.thread_id == thread_id { + return; + } + self.thread_id = thread_id; + self.screen = Screen::new(FALLBACK_GRID); + self.ready = false; + self.ended = false; + self.requested = None; + self.scroll_remainder = 0.; + } + + /// Applies one element of the attach stream; an element for another thread is stale. + pub fn apply(&mut self, thread_id: &str, event: Event, cx: &mut Context) { + if self.thread_id.as_deref() != Some(thread_id) { + return; + } + match event { + Event::Snapshot { + history, + grid, + exited, + } => { + let replies = self.screen.reset(&history, grid.unwrap_or(FALLBACK_GRID)); + self.ready = true; + self.ended = false; + self.requested = None; + self.reply(replies); + if exited { + self.end("Process exited"); + } + } + Event::Restarted { history, grid } => { + let replies = self.screen.reset(&history, grid.unwrap_or(FALLBACK_GRID)); + self.ended = false; + self.requested = None; + self.reply(replies); + } + Event::Output(data) => { + let replies = self.screen.feed(&data); + self.reply(replies); + } + Event::Cleared => { + self.screen.feed("\x1b[2J\x1b[3J\x1b[H"); + } + Event::Resized(grid) => self.screen.resize(grid), + Event::Exited => self.end("Process exited"), + Event::Closed => self.end("Terminal closed"), + Event::Error(message) => self.end(&format!("Terminal error: {message}")), + } + cx.notify(); + } + + /// Prints why the shell is gone, and releases the server's session as the web does on exit. + fn end(&mut self, message: &str) { + if self.ended { + return; + } + self.ended = true; + self.screen + .feed(&format!("\r\n\x1b[2m[{message}]\x1b[0m\r\n")); + if let Some(thread_id) = self.thread_id.clone() { + self.send(Action::TerminalClose { thread_id }); + } + } + + fn send(&self, action: Action) { + if self.actions.try_send(action).is_err() { + eprintln!("[ui] could not reach the terminal"); + } + } + + fn write(&mut self, data: String) { + let Some(thread_id) = self.thread_id.clone().filter(|_| self.ready && !self.ended) else { + return; + }; + self.screen.snap_to_bottom(); + self.send(Action::TerminalWrite { thread_id, data }); + } + + /// What the shell's terminal queries asked to be written back. + fn reply(&mut self, replies: Vec) { + for reply in replies { + self.write(reply); + } + } + + fn key_down(&mut self, event: &KeyDownEvent, cx: &mut Context) { + let keystroke = &event.keystroke; + if keystroke.modifiers.platform && keystroke.key == "v" { + let text = cx.read_from_clipboard().and_then(|item| item.text()); + if let Some(text) = text.filter(|text| !text.is_empty()) { + self.write(terminal::paste_text(&text, self.screen.bracketed_paste())); + } + cx.stop_propagation(); + return; + } + if let Some(data) = terminal::encode_key(keystroke, self.screen.application_cursor()) { + self.write(data); + cx.stop_propagation(); + cx.notify(); + } + } + + fn scroll(&mut self, event: &ScrollEvent, line_height: f32, cx: &mut Context) { + if self.screen.alternate_screen() { + return; + } + let moved = f32::from(event.delta.pixel_delta(px(line_height)).y) / line_height; + self.scroll_remainder += moved; + let lines = self.scroll_remainder.trunc(); + if lines != 0. { + self.scroll_remainder -= lines; + self.screen.scroll(lines as i32); + cx.notify(); + } + cx.stop_propagation(); + } + + /// Fits the grid to the painted area and tells the server when it changed. + fn fit(&mut self, cell_width: f32, line_height: f32) { + let area = self.area.get(); + let Some(grid) = Grid::fit( + f32::from(area.size.width), + f32::from(area.size.height), + cell_width, + line_height, + ) else { + return; + }; + if !self.ready || self.ended || self.requested == Some(grid) { + return; + } + self.requested = Some(grid); + self.screen.resize(grid); + if let Some(thread_id) = self.thread_id.clone() { + self.send(Action::TerminalResize { thread_id, grid }); + } + } +} + +impl Focusable for TerminalView { + fn focus_handle(&self, _: &App) -> FocusHandle { + self.focus.clone() + } +} + +fn rgb_of(color: Hsla) -> Rgb { + let color = Rgba::from(color); + let byte = |value: f32| (value.clamp(0., 1.) * 255.).round() as u8; + Rgb { + r: byte(color.r), + g: byte(color.g), + b: byte(color.b), + } +} + +fn hsla_of(color: Rgb) -> Hsla { + gpui::rgb((color.r as u32) << 16 | (color.g as u32) << 8 | color.b as u32).into() +} + +fn runs(spans: &[Span], font: &Font) -> Vec { + spans + .iter() + .map(|span| { + let style = span.style; + let color = hsla_of(style.foreground); + TextRun { + len: span.len, + font: Font { + weight: if style.bold { + FontWeight::BOLD + } else { + font.weight + }, + style: if style.italic { + FontStyle::Italic + } else { + font.style + }, + ..font.clone() + }, + color, + background_color: style.background.map(hsla_of), + underline: style.underline.then(|| UnderlineStyle { + thickness: px(1.), + color: Some(color), + wavy: false, + }), + strikethrough: style.strikethrough.then(|| StrikethroughStyle { + thickness: px(1.), + color: Some(color), + }), + } + }) + .collect() +} + +impl Render for TerminalView { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let family = theme.mono_font_family.clone(); + let size = theme.mono_font_size; + let background = theme.background; + let palette = Palette::dark(rgb_of(theme.foreground), rgb_of(background)); + let font = gpui::font(family.clone()); + let line_height = (f32::from(size) * LINE_HEIGHT_RATIO).round(); + let cell_width = { + let text_system = window.text_system(); + let font_id = text_system.resolve_font(&font); + text_system + .advance(font_id, size, 'm') + .map_or(f32::from(size) * 0.6, |advance| f32::from(advance.width)) + }; + self.fit(cell_width, line_height); + + let focused = self.focus.is_focused(window); + let (rows, cursor) = self.screen.rows(&palette); + let cursor_colors = if focused { + (palette.background, palette.foreground) + } else { + (palette.foreground, palette.foreground) + }; + let lines: Vec<_> = rows + .iter() + .enumerate() + .map(|(index, row): (usize, &Row)| { + let spans = match cursor.filter(|(line, _)| *line == index) { + Some((_, column)) if focused => { + mark_cursor(row, column, cursor_colors.0, cursor_colors.1) + } + Some((_, column)) => { + // Unfocused, the cursor is a dim block rather than a solid one. + let dim = Rgb { + r: (palette.foreground.r as u16 / 2) as u8, + g: (palette.foreground.g as u16 / 2) as u8, + b: (palette.foreground.b as u16 / 2) as u8, + }; + mark_cursor(row, column, palette.foreground, dim) + } + None => row.spans.clone(), + }; + div() + .h(px(line_height)) + .flex_none() + .whitespace_nowrap() + .child(StyledText::new(row.text.clone()).with_runs(runs(&spans, &font))) + }) + .collect(); + + let measured = self.area.clone(); + div() + .id("terminal") + .key_context("Terminal") + .track_focus(&self.focus) + .size_full() + .overflow_hidden() + .bg(background) + .font_family(family) + .text_size(size) + .line_height(px(line_height)) + .text_color(theme.foreground) + .px(px(PADDING_X)) + .py(px(PADDING_Y)) + .cursor_text() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _, window, cx| this.focus(window, cx)), + ) + .on_key_down(cx.listener(|this, event: &KeyDownEvent, _, cx| this.key_down(event, cx))) + .on_scroll(cx.listener(move |this, event: &ScrollEvent, _, cx| { + this.scroll(event, line_height, cx) + })) + .child( + div() + .relative() + .size_full() + .child( + canvas( + move |bounds, window, _| { + if measured.replace(bounds) != bounds { + window.refresh(); + } + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .children(lines), + ) + } +} diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 5c72d515d797..ecdc02d58861 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -12,6 +12,8 @@ use crate::strip::{ self, Branch, Defaults, EnvMode, HOST_SHORTCUT, PERMISSIONS_SHORTCUT, RuntimeMode, WORKSPACE_SHORTCUT, }; +use crate::terminal::{self, Target}; +use crate::terminal_view::TerminalView; use crate::thread::{Entry, Kind}; use crate::thread_card::{self, ChainKind, PrLine}; use gpui::{ @@ -39,6 +41,8 @@ use std::{ time::Duration, }; +/// The drag handle on the terminal drawer's top edge. +const TERMINAL_HANDLE_HEIGHT: f32 = 8.; /// The web's `THREAD_SIDEBAR_DEFAULT_WIDTH`. const SIDEBAR_DEFAULT_WIDTH: f32 = 256.; /// The web's `THREAD_SIDEBAR_MIN_WIDTH`, `13 * 16`. @@ -65,6 +69,7 @@ actions!( GoBack, GoForward, ToggleDetails, + ToggleTerminal, OpenFavoriteEditor ] ); @@ -83,6 +88,16 @@ pub const OVERLAY_CONTEXT: &str = "SidebarOverlay"; #[derive(Clone)] struct SidebarResize; +/// The drag payload of the terminal drawer's top edge. +#[derive(Clone)] +struct TerminalResizeDrag; + +impl Render for TerminalResizeDrag { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + impl Render for SidebarResize { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { gpui::Empty @@ -196,6 +211,13 @@ pub struct MainView { editor_pick: Option, editor_menu_open: bool, editor_menu: Entity, + /// Threads (and drafts, by project) whose terminal drawer is open; it starts closed. + terminal_open: HashSet, + /// The drawer's height per thread, as dragged. + terminal_heights: HashMap, + terminal: Entity, + /// The terminal the connection was last asked to attach. + terminal_attached: Option, /// The main area's width at the last render, for fitting the details card. main_width: Cell, actions: ActionSender, @@ -241,6 +263,7 @@ impl MainView { editors: Vec::new(), preferred: None, }); + let terminal_actions = actions.clone(); Self { status, search: cx.new(|cx| InputState::new(window, cx).placeholder("Search")), @@ -280,6 +303,10 @@ impl MainView { editor_pick: None, editor_menu_open: false, editor_menu, + terminal_open: HashSet::new(), + terminal_heights: HashMap::new(), + terminal: cx.new(|cx| TerminalView::new(terminal_actions, cx)), + terminal_attached: None, main_width: Cell::new(0.), actions, } @@ -309,6 +336,11 @@ impl MainView { self.vcs.insert(cwd, status); return cx.notify(); } + if let Status::Terminal { thread_id, event } = status { + self.terminal + .update(cx, |view, cx| view.apply(&thread_id, event, cx)); + return; + } if matches!(status, Status::Connecting) { self.branches_requested.clear(); self.vcs.clear(); @@ -329,6 +361,7 @@ impl MainView { } self.request_branch(); self.sync_vcs(); + self.sync_terminal(cx); cx.notify(); } @@ -385,6 +418,7 @@ impl MainView { self.sync_thread(); self.request_branch(); self.sync_vcs(); + self.sync_terminal(cx); cx.notify(); } @@ -498,6 +532,95 @@ impl MainView { } } + /// Where the open thread's terminal runs, which needs the same directory the card works on. + fn terminal_target(&self) -> Option { + let key = self.details_key()?; + let cwd = self.details_cwd()?; + let shell = self.shell()?; + let row = self + .selected_thread + .as_deref() + .and_then(|id| shell.rows.iter().find(|row| row.id == id)); + let worktree_path = row.and_then(|row| row.worktree_path.clone()); + let entry = match row { + Some(row) => shell + .project_entries + .iter() + .find(|entry| entry.contains(&row.project_id)), + None => active_project(self.selected_project.as_deref(), shell), + }; + let project_root = entry + .map(|entry| entry.root.clone()) + .filter(|root| !root.is_empty()) + .unwrap_or(cwd); + Some(Target { + thread_id: key, + project_root, + worktree_path, + }) + } + + fn terminal_visible(&self) -> bool { + self.details_key() + .is_some_and(|key| self.terminal_open.contains(&key)) + && self.terminal_target().is_some() + } + + /// The web's `terminal.toggle`. Closing the drawer leaves the shell running. + fn toggle_terminal(&mut self, window: &mut Window, cx: &mut Context) { + let Some(key) = self.details_key() else { + return; + }; + if self.terminal_target().is_none() { + return; + } + let opened = self.terminal_open.insert(key.clone()); + if !opened { + self.terminal_open.remove(&key); + } + self.editor_menu_open = false; + self.sync_terminal(cx); + if opened { + let handle = self.terminal.focus_handle(cx); + window.focus(&handle, cx); + } + cx.notify(); + } + + /// Attaches the open drawer's terminal to the connection, and detaches when none shows. + fn sync_terminal(&mut self, cx: &mut Context) { + let wanted = self + .terminal_visible() + .then(|| self.terminal_target()) + .flatten(); + if wanted == self.terminal_attached { + return; + } + let thread_id = wanted.as_ref().map(|target| target.thread_id.clone()); + self.terminal.update(cx, |view, _| view.show(thread_id)); + self.terminal_attached = wanted.clone(); + if self + .actions + .try_send(Action::AttachTerminal(wanted)) + .is_err() + { + eprintln!("[ui] could not ask for the terminal"); + } + } + + /// The drawer's height: as dragged, within what the window and chat column leave. + fn terminal_height(&self, viewport_height: f32) -> f32 { + let max = terminal::max_height( + viewport_height, + viewport_height - TOPBAR_HEIGHT - self.composer_height.get(), + ); + let dragged = self + .details_key() + .and_then(|key| self.terminal_heights.get(&key).copied()) + .unwrap_or(terminal::DEFAULT_HEIGHT); + terminal::clamp_height(dragged, max) + } + fn installed_editors(&self) -> Vec<&'static str> { self.providers.as_ref().map_or_else(Vec::new, |catalog| { details::installed_editors(catalog.available_editors()) @@ -587,12 +710,29 @@ impl MainView { row.child(self.breadcrumb(active, title, cx)) .child(div().flex_1()) .when(self.details_cwd().is_some(), |row| { - row.child(self.details_toggle(cx)) + row.child(self.terminal_toggle(cx)) + .child(self.details_toggle(cx)) }) }), ) } + /// The web's `PanelLayoutControls` terminal drawer toggle, pressed while the drawer is open. + fn terminal_toggle(&self, cx: &mut Context) -> impl IntoElement { + let pressed = self.terminal_visible(); + let foreground = cx.theme().foreground; + header_icon_button( + "toggle-terminal", + Icons::PanelBottom, + format!("Toggle terminal drawer ({})", terminal::TOGGLE_SHORTCUT).into(), + cx, + ) + .when(pressed, move |button| { + button.bg(foreground.opacity(0.06)).text_color(foreground) + }) + .on_click(cx.listener(|this, _, window, cx| this.toggle_terminal(window, cx))) + } + /// The web's `PanelLayoutControls` thread details toggle, pressed while the card is open. fn details_toggle(&self, cx: &mut Context) -> impl IntoElement { let pressed = self.details_open(); @@ -1256,22 +1396,81 @@ impl MainView { /// The main content, with the thread details card floating at its top right when it fits. fn main_area(&self, viewport_height: f32, cx: &mut Context) -> AnyElement { + let drawer = self.terminal_drawer(viewport_height, cx); + let drawer_height = if self.terminal_visible() { + self.terminal_height(viewport_height) + } else { + 0. + }; let content = self.main_content(viewport_height, cx); - let card = self.details_card(viewport_height - TOPBAR_HEIGHT, cx); - div() - .relative() - .flex() + let card = self.details_card(viewport_height - TOPBAR_HEIGHT - drawer_height, cx); + v_flex() .flex_1() .min_w_0() .h_full() - .when(card.is_some(), |area| { - area.pr(px(details::CARD_WIDTH + details::CARD_GAP + 32.)) - }) - .child(content) - .children(card) + .child( + div() + .relative() + .flex() + .flex_1() + .min_h_0() + .w_full() + .when(card.is_some(), |area| { + area.pr(px(details::CARD_WIDTH + details::CARD_GAP + 32.)) + }) + .child(content) + .children(card), + ) + .children(drawer) .into_any_element() } + /// The web's `ThreadTerminalDrawer`: the thread's shell under the chat, its top edge draggable. + fn terminal_drawer(&self, viewport_height: f32, cx: &mut Context) -> Option { + if !self.terminal_visible() { + return None; + } + let line = cx.theme().border; + let height = self.terminal_height(viewport_height); + Some( + div() + .relative() + .flex_none() + .w_full() + .h(px(height)) + .border_t_1() + .border_color(line) + .child(self.terminal.clone()) + .child( + div() + .id("terminal-resize") + .absolute() + .top(px(-TERMINAL_HANDLE_HEIGHT / 2.)) + .left_0() + .right_0() + .h(px(TERMINAL_HANDLE_HEIGHT)) + .occlude() + .cursor_row_resize() + .on_drag(TerminalResizeDrag, |drag, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| drag.clone()) + }) + .on_drag_move(cx.listener( + |this, event: &DragMoveEvent, window, cx| { + let Some(key) = this.details_key() else { + return; + }; + let viewport = f32::from(window.viewport_size().height); + let dragged = viewport - f32::from(event.event.position.y); + this.terminal_heights.insert(key, dragged); + cx.notify(); + }, + )), + ) + .into_any_element(), + ) + } + fn main_content(&self, viewport_height: f32, cx: &mut Context) -> AnyElement { let Some(shell) = self.shell() else { return centered(cx) @@ -3525,6 +3724,11 @@ impl Render for MainView { ) .on_action(cx.listener(|this, _: &OpenPermissions, _, cx| this.open_permissions(cx))) .on_action(cx.listener(|this, _: &ToggleDetails, _, cx| this.toggle_details(cx))) + .on_action( + cx.listener(|this, _: &ToggleTerminal, window, cx| { + this.toggle_terminal(window, cx) + }), + ) .on_action( cx.listener(|this, _: &OpenFavoriteEditor, _, cx| this.open_favorite_editor(cx)), ) From ec5e1987d76af28d55bc7f33afd069ac0fa1d3c0 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 12:49:37 -0500 Subject: [PATCH 29/82] feat(desktop-gpui): right panel shows the workspace diff Adds the right panel toggle (alt-cmd-b), a resizable docked column that becomes a sheet below 980px, and a Diff surface listing the working tree's changed files with unified hunks. The details card's Changes row opens it. --- apps/desktop-gpui/src/connection.rs | 51 +- apps/desktop-gpui/src/diff.rs | 446 +++++++++++++++ apps/desktop-gpui/src/main.rs | 2 + apps/desktop-gpui/src/rpc.rs | 9 + apps/desktop-gpui/src/status.rs | 7 + apps/desktop-gpui/src/ui.rs | 57 +- apps/desktop-gpui/src/ui/right_panel.rs | 693 ++++++++++++++++++++++++ 7 files changed, 1255 insertions(+), 10 deletions(-) create mode 100644 apps/desktop-gpui/src/diff.rs create mode 100644 apps/desktop-gpui/src/ui/right_panel.rs diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 0269e021f536..501e88981145 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -2,13 +2,14 @@ use crate::auth::{self, AuthError, Credential}; use crate::details::VcsStatus; +use crate::diff; use crate::electron_dpop; use crate::projects::MachineKind; use crate::providers::Catalog; use crate::rpc::{ - Event, PING_FRAME, ProjectsMutate, ServerGetConfig, Session, ShellOpenInEditor, SubscribeShell, - SubscribeThread, SubscribeVcsStatus, TerminalAttach, TerminalClose, TerminalResize, - TerminalWrite, VcsListRefs, + Event, PING_FRAME, ProjectsMutate, ReviewGetDiffPreview, ServerGetConfig, Session, + ShellOpenInEditor, SubscribeShell, SubscribeThread, SubscribeVcsStatus, TerminalAttach, + TerminalClose, TerminalResize, TerminalWrite, VcsListRefs, }; use crate::shell::ShellState; use crate::status::{Status, StatusSender, publish}; @@ -56,6 +57,10 @@ pub enum Action { SelectThread(Option), /// The directory whose version control status the details card shows; `None` stops watching. WatchVcs(Option), + /// The working-tree diff of `cwd`, for the right panel's Diff surface. + LoadDiff { + cwd: String, + }, /// Launches `editor` (an `EditorId`) on the server's machine at `cwd`. OpenInEditor { cwd: String, @@ -88,6 +93,7 @@ impl Action { Self::LoadBranch { .. } => "load the branch", Self::SelectThread(_) => "open the thread", Self::WatchVcs(_) => "watch the version control status", + Self::LoadDiff { .. } => "load the diff", Self::OpenInEditor { .. } => "open in the editor", Self::AttachTerminal(_) => "attach the terminal", Self::TerminalWrite { .. } => "write to the terminal", @@ -350,6 +356,8 @@ async fn serve( let mut pending: HashMap = HashMap::new(); // Branch lookups by request id, with the workspace root each was for. let mut branches: HashMap = HashMap::new(); + // Diff loads by request id, with the directory each was for. + let mut diffs: HashMap = HashMap::new(); let mut ping = interval(PING_INTERVAL); ping.set_missed_tick_behavior(MissedTickBehavior::Delay); let mut deadline = Instant::now() + PING_TIMEOUT; @@ -402,6 +410,18 @@ async fn serve( Action::TerminalClose { thread_id } => session .request::(&terminal::close_payload(thread_id)) .map(|(id, frame)| (id, frame, None)), + Action::LoadDiff { cwd } => { + match session.request::(&diff_query(cwd)) { + Ok((id, frame)) => { + diffs.insert(id, cwd.clone()); + if let Err(error) = sink.send(Message::text(frame)).await { + return error.to_string(); + } + } + Err(error) => eprintln!("[rpc] could not {}: {error}", action.describe()), + } + continue; + } Action::OpenInEditor { cwd, editor } => session .request::(&serde_json::json!({ "cwd": cwd, @@ -464,6 +484,10 @@ async fn serve( publish_catalog(event, status); continue; } + if let Some(cwd) = request_id(&event).and_then(|id| diffs.remove(id)) { + publish_diff(cwd, event, status); + continue; + } if let Some(root) = request_id(&event).and_then(|id| branches.remove(id)) { publish_branch(root, event, status); continue; @@ -785,6 +809,27 @@ fn publish_branch(root: String, event: Event, status: &StatusSender) { publish(status, Status::Branch { root, branch }); } +/// The web's `branchDiffPreviewInput` for the unstaged scope. +fn diff_query(cwd: &str) -> serde_json::Value { + serde_json::json!({ + "cwd": cwd, + "sourceKind": "working-tree", + "ignoreWhitespace": false, + }) +} + +/// Publishes a directory's diff, or the reason it could not be loaded. +fn publish_diff(cwd: String, event: Event, status: &StatusSender) { + let diff = match event { + Event::Done { value, .. } => { + diff::Preview::decode(&value).ok_or_else(|| "The server sent no diff.".to_owned()) + } + Event::Failed { message, .. } => Err(message), + _ => return, + }; + publish(status, Status::Diff { cwd, diff }); +} + /// Publishes the provider catalog; a failure leaves the composer without a model trigger. fn publish_catalog(event: Event, status: &StatusSender) { match event { diff --git a/apps/desktop-gpui/src/diff.rs b/apps/desktop-gpui/src/diff.rs new file mode 100644 index 000000000000..21d70be60125 --- /dev/null +++ b/apps/desktop-gpui/src/diff.rs @@ -0,0 +1,446 @@ +//! The diff surface's data: the `review.getDiffPreview` result (`ReviewDiffPreviewResult` in +//! `packages/contracts/src/review.ts`) and the unified patch it carries, parsed into the files, +//! hunks and lines `DiffPanel.tsx` shows. The review RPCs are not in the generated wire types, +//! so the reply is read from JSON like `strip::Branch`. + +use serde_json::Value; +use std::cmp::Ordering; + +/// The web's `formatCompactDiffCount` (`DiffStatLabel.tsx`). +pub fn format_count(value: u64) -> String { + let compact = |value: f64, unit: &str| { + let text = if value < 10. { + format!("{value:.1}").trim_end_matches(".0").to_owned() + } else { + format!("{}", value.round()) + }; + format!("{text}{unit}") + }; + if value < 1_000 { + value.to_string() + } else if value < 999_500 { + compact(value as f64 / 1_000., "k") + } else if value < 999_500_000 { + compact(value as f64 / 1_000_000., "m") + } else { + compact(value as f64 / 1_000_000_000., "b") + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Change { + Modified, + Added, + Deleted, + Renamed, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Context, + Added, + Removed, + /// `\ No newline at end of file`. + Note, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Line { + pub kind: Kind, + pub old: Option, + pub new: Option, + pub text: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Hunk { + pub header: String, + pub lines: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct File { + pub path: String, + pub previous_path: Option, + pub change: Change, + pub binary: bool, + pub additions: u64, + pub deletions: u64, + pub hunks: Vec, +} + +/// One diff source: the working tree against `HEAD`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Preview { + pub files: Vec, + /// The server cut the patch short, so some files have no hunks; counts still cover them. + pub truncated: bool, +} + +impl Preview { + pub fn totals(&self) -> (u64, u64) { + self.files + .iter() + .fold((0, 0), |(additions, deletions), file| { + (additions + file.additions, deletions + file.deletions) + }) + } + + /// Reads a `ReviewDiffPreviewResult`, using its first source. The server's per-file stats + /// win over counted lines, since a truncated patch undercounts. + pub fn decode(value: &Value) -> Option { + let source = value.get("sources")?.as_array()?.first()?; + let diff = source.get("diff").and_then(Value::as_str).unwrap_or(""); + let mut files = parse(diff); + let flag = |holder: &Value, key| holder.get(key).and_then(Value::as_bool) == Some(true); + let truncated = flag(source, "truncated") + || flag(source, "patchesDeferred") + || flag(value, "incomplete"); + for stat in source + .get("files") + .and_then(Value::as_array) + .into_iter() + .flatten() + { + let Some(path) = stat.get("path").and_then(Value::as_str) else { + continue; + }; + let count = |key| stat.get(key).and_then(Value::as_u64).unwrap_or(0); + let (additions, deletions) = (count("additions"), count("deletions")); + match files.iter_mut().find(|file| file.path == path) { + Some(file) => (file.additions, file.deletions) = (additions, deletions), + None => files.push(File { + path: path.to_owned(), + previous_path: stat + .get("previousPath") + .and_then(Value::as_str) + .map(str::to_owned), + change: Change::Modified, + binary: false, + additions, + deletions, + hunks: Vec::new(), + }), + } + } + files.sort_by(|a, b| natural_cmp(&a.path, &b.path)); + Some(Self { files, truncated }) + } +} + +/// Paths in file order, digits compared as numbers, like the web's `Intl.Collator` with `numeric`. +fn natural_cmp(a: &str, b: &str) -> Ordering { + fn number(chars: &mut std::iter::Peekable) -> String { + let mut digits = String::new(); + while let Some(digit) = chars.next_if(char::is_ascii_digit) { + digits.push(digit); + } + digits.trim_start_matches('0').to_owned() + } + let (mut a, mut b) = (a.chars().peekable(), b.chars().peekable()); + loop { + match (a.peek().copied(), b.peek().copied()) { + (None, None) => return Ordering::Equal, + (None, Some(_)) => return Ordering::Less, + (Some(_), None) => return Ordering::Greater, + (Some(x), Some(y)) if x.is_ascii_digit() && y.is_ascii_digit() => { + let (left, right) = (number(&mut a), number(&mut b)); + let order = left.len().cmp(&right.len()).then_with(|| left.cmp(&right)); + if order.is_ne() { + return order; + } + } + (Some(x), Some(y)) => { + let order = x.to_lowercase().cmp(y.to_lowercase()); + if order.is_ne() { + return order; + } + a.next(); + b.next(); + } + } + } +} + +/// Splits a unified diff (`git diff` output) into files, hunks and numbered lines. +pub fn parse(patch: &str) -> Vec { + let mut files: Vec = Vec::new(); + let mut counters = (0u32, 0u32); + let mut in_hunk = false; + for raw in patch.lines() { + if let Some(rest) = raw.strip_prefix("diff --git ") { + let (old, new) = split_git_header(rest); + files.push(File { + path: new, + previous_path: Some(old), + change: Change::Modified, + binary: false, + additions: 0, + deletions: 0, + hunks: Vec::new(), + }); + in_hunk = false; + continue; + } + let Some(file) = files.last_mut() else { + continue; + }; + if !in_hunk { + if raw.starts_with("new file mode") { + file.change = Change::Added; + } else if raw.starts_with("deleted file mode") { + file.change = Change::Deleted; + } else if let Some(path) = raw.strip_prefix("rename from ") { + file.change = Change::Renamed; + file.previous_path = Some(path.to_owned()); + } else if let Some(path) = raw.strip_prefix("rename to ") { + file.path = path.to_owned(); + } else if raw.starts_with("Binary files ") || raw.starts_with("GIT binary patch") { + file.binary = true; + } else if let Some(path) = raw.strip_prefix("--- ") { + if let Some(path) = side_path(path, "a/") { + file.previous_path = Some(path); + } + } else if let Some(path) = raw.strip_prefix("+++ ").and_then(|p| side_path(p, "b/")) { + file.path = path; + } + } + if let Some(header) = raw.strip_prefix("@@ ") { + let Some((old, new)) = hunk_starts(header) else { + continue; + }; + counters = (old, new); + in_hunk = true; + file.hunks.push(Hunk { + header: raw.to_owned(), + lines: Vec::new(), + }); + continue; + } + if !in_hunk { + continue; + } + let Some(hunk) = file.hunks.last_mut() else { + continue; + }; + let (kind, text) = match raw.as_bytes().first() { + Some(b'+') => (Kind::Added, &raw[1..]), + Some(b'-') => (Kind::Removed, &raw[1..]), + Some(b'\\') => (Kind::Note, raw), + Some(b' ') => (Kind::Context, &raw[1..]), + None => (Kind::Context, raw), + Some(_) => continue, + }; + let (old, new) = match kind { + Kind::Added => { + file.additions += 1; + counters.1 += 1; + (None, Some(counters.1 - 1)) + } + Kind::Removed => { + file.deletions += 1; + counters.0 += 1; + (Some(counters.0 - 1), None) + } + Kind::Context => { + counters.0 += 1; + counters.1 += 1; + (Some(counters.0 - 1), Some(counters.1 - 1)) + } + Kind::Note => (None, None), + }; + hunk.lines.push(Line { + kind, + old, + new, + text: text.to_owned(), + }); + } + for file in &mut files { + if file.previous_path.as_deref() == Some(file.path.as_str()) { + file.previous_path = None; + } + } + files +} + +/// `a/old b/new` from a `diff --git` line; with spaces in names the split is a guess, which the +/// `---` and `+++` lines correct when the file has hunks. +fn split_git_header(rest: &str) -> (String, String) { + let strip = |text: &str| { + let text = text.trim_matches('"'); + let text = text + .strip_prefix("a/") + .or(text.strip_prefix("b/")) + .unwrap_or(text); + text.to_owned() + }; + if let Some(index) = rest.find(" b/") { + let (old, new) = rest.split_at(index); + return (strip(old), strip(new.trim_start())); + } + let (old, new) = rest.split_once(' ').unwrap_or((rest, rest)); + (strip(old), strip(new)) +} + +/// A `---` or `+++` path without its `a/` or `b/` prefix; `/dev/null` is no path. +fn side_path(text: &str, prefix: &str) -> Option { + let text = text.split('\t').next().unwrap_or(text).trim_matches('"'); + if text == "/dev/null" { + return None; + } + Some(text.strip_prefix(prefix).unwrap_or(text).to_owned()) +} + +/// The old and new start lines of `-a,b +c,d @@ ...`. +fn hunk_starts(header: &str) -> Option<(u32, u32)> { + let ranges = header.split(" @@").next()?; + let (old, new) = ranges.split_once(' ')?; + let start = |range: &str, sign: char| { + range + .strip_prefix(sign)? + .split(',') + .next()? + .parse::() + .ok() + }; + Some((start(old, '-')?, start(new, '+')?)) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const PATCH: &str = "diff --git a/src/a.rs b/src/a.rs +index 111..222 100644 +--- a/src/a.rs ++++ b/src/a.rs +@@ -1,3 +1,3 @@ fn main + keep +-old ++new + tail +@@ -10,2 +10,3 @@ + ctx ++added + more +\\ No newline at end of file +diff --git a/new.txt b/new.txt +new file mode 100644 +--- /dev/null ++++ b/new.txt +@@ -0,0 +1,2 @@ ++one ++two +diff --git a/gone.txt b/gone.txt +deleted file mode 100644 +--- a/gone.txt ++++ /dev/null +@@ -1 +0,0 @@ +-bye +diff --git a/img.png b/img.png +Binary files a/img.png and b/img.png differ +diff --git a/before.rs b/after.rs +similarity index 90% +rename from before.rs +rename to after.rs +"; + + #[test] + fn a_patch_splits_into_files_with_counts_and_kinds() { + let files = parse(PATCH); + let summary: Vec<_> = files + .iter() + .map(|file| { + ( + file.path.as_str(), + file.change, + file.additions, + file.deletions, + ) + }) + .collect(); + assert_eq!( + summary, + [ + ("src/a.rs", Change::Modified, 2, 1), + ("new.txt", Change::Added, 2, 0), + ("gone.txt", Change::Deleted, 0, 1), + ("img.png", Change::Modified, 0, 0), + ("after.rs", Change::Renamed, 0, 0), + ] + ); + assert!(files[3].binary); + assert_eq!(files[4].previous_path.as_deref(), Some("before.rs")); + assert_eq!(files[0].previous_path, None); + } + + #[test] + fn lines_carry_their_numbers_per_side() { + let files = parse(PATCH); + let hunk = &files[0].hunks[0]; + assert_eq!(hunk.header, "@@ -1,3 +1,3 @@ fn main"); + let numbers: Vec<_> = hunk + .lines + .iter() + .map(|line| (line.kind, line.old, line.new, line.text.as_str())) + .collect(); + assert_eq!( + numbers, + [ + (Kind::Context, Some(1), Some(1), "keep"), + (Kind::Removed, Some(2), None, "old"), + (Kind::Added, None, Some(2), "new"), + (Kind::Context, Some(3), Some(3), "tail"), + ] + ); + let second = &files[0].hunks[1]; + assert_eq!(second.lines[1].new, Some(11)); + assert_eq!(second.lines[3].kind, Kind::Note); + } + + #[test] + fn the_servers_stats_win_and_list_files_the_patch_lacks() { + let value = json!({"sources": [{ + "diff": PATCH, + "truncated": true, + "files": [ + {"path": "src/a.rs", "previousPath": null, "additions": 40, "deletions": 3}, + {"path": "big.lock", "previousPath": null, "additions": 9000, "deletions": 0}, + ], + }]}); + let preview = Preview::decode(&value).expect("preview"); + assert!(preview.truncated); + let big = preview + .files + .iter() + .find(|f| f.path == "big.lock") + .expect("big"); + assert!(big.hunks.is_empty()); + let a = preview + .files + .iter() + .find(|f| f.path == "src/a.rs") + .expect("a"); + assert_eq!((a.additions, a.deletions), (40, 3)); + assert_eq!(preview.totals(), (9042, 4)); + assert!(Preview::decode(&json!({"sources": []})).is_none()); + } + + #[test] + fn files_sort_with_numbers_compared_as_numbers() { + let mut names = ["b10.rs", "b2.rs", "A.rs"]; + names.sort_by(|a, b| natural_cmp(a, b)); + assert_eq!(names, ["A.rs", "b2.rs", "b10.rs"]); + } + + #[test] + fn counts_compact_like_the_web() { + let shown: Vec<_> = [0, 999, 1000, 1300, 12_400, 999_499, 1_500_000] + .into_iter() + .map(format_count) + .collect(); + assert_eq!(shown, ["0", "999", "1k", "1.3k", "12k", "999k", "1.5m"]); + } +} diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 58f2a00b3c44..bf5766bccc2d 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -4,6 +4,7 @@ mod catalog; mod config; mod connection; mod details; +mod diff; mod electron_dpop; mod environments; mod history; @@ -102,6 +103,7 @@ fn main() { KeyBinding::new("shift-cmd-a", ui::OpenPermissions, None), KeyBinding::new("shift-cmd-b", ui::ToggleDetails, None), KeyBinding::new("cmd-j", ui::ToggleTerminal, None), + KeyBinding::new("alt-cmd-b", ui::ToggleRightPanel, None), KeyBinding::new("cmd-o", ui::OpenFavoriteEditor, None), KeyBinding::new("cmd-[", ui::GoBack, None), KeyBinding::new("cmd-]", ui::GoForward, None), diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs index a0feafedf4d9..2baf2119c335 100644 --- a/apps/desktop-gpui/src/rpc.rs +++ b/apps/desktop-gpui/src/rpc.rs @@ -72,6 +72,15 @@ impl Method for ShellOpenInEditor { type Success = Value; } +/// `review.getDiffPreview`, a unary call. The wire types are not generated; `diff::Preview` decodes it. +pub struct ReviewGetDiffPreview; + +impl Method for ReviewGetDiffPreview { + const TAG: &'static str = "review.getDiffPreview"; + type Payload = Value; + type Success = Value; +} + /// `subscribeVcsStatus`, a stream of snapshot and update events; `details::VcsStatus` reads them. pub struct SubscribeVcsStatus; diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index 8f7f1b8137ff..4a08daf12a38 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -1,4 +1,5 @@ use crate::details::VcsStatus; +use crate::diff; use crate::projects::{EnvironmentProjects, MachineKind, ProjectEntry}; use crate::providers::Catalog; use crate::strip::Branch; @@ -48,6 +49,11 @@ pub enum Status { thread_id: String, event: terminal::Event, }, + /// The working-tree diff of a directory, or why it could not be loaded. + Diff { + cwd: String, + diff: Result, + }, } /// Where the web sidebar lists a thread. @@ -196,6 +202,7 @@ impl fmt::Display for Status { Self::Branch { root, .. } => write!(f, "Branch of {root}"), Self::Vcs { cwd, .. } => write!(f, "Status of {cwd}"), Self::Terminal { thread_id, .. } => write!(f, "Terminal of {thread_id}"), + Self::Diff { cwd, .. } => write!(f, "Diff of {cwd}"), } } } diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index ecdc02d58861..df5162da26f6 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -43,6 +43,8 @@ use std::{ /// The drag handle on the terminal drawer's top edge. const TERMINAL_HANDLE_HEIGHT: f32 = 8.; +mod right_panel; + /// The web's `THREAD_SIDEBAR_DEFAULT_WIDTH`. const SIDEBAR_DEFAULT_WIDTH: f32 = 256.; /// The web's `THREAD_SIDEBAR_MIN_WIDTH`, `13 * 16`. @@ -70,6 +72,7 @@ actions!( GoForward, ToggleDetails, ToggleTerminal, + ToggleRightPanel, OpenFavoriteEditor ] ); @@ -207,6 +210,15 @@ pub struct MainView { vcs: HashMap>, /// The directory the connection was last asked to watch. vcs_watching: Option, + /// Threads whose right panel is open, in memory like the web's `rightPanelStore` state. + right_panel_open: HashSet, + /// The docked panel's width as dragged, before fitting the window. + right_panel_width: f32, + /// The working-tree diff per directory the panel loaded. + diffs: HashMap, + /// Directories whose diff was asked for on this connection. + diffs_requested: HashSet, + diff_list: Entity, /// The editor last picked or launched, in place of the web's `t3code:last-editor`. editor_pick: Option, editor_menu_open: bool, @@ -300,6 +312,11 @@ impl MainView { details_closed: HashSet::new(), vcs: HashMap::new(), vcs_watching: None, + right_panel_open: HashSet::new(), + right_panel_width: right_panel::DEFAULT_WIDTH, + diffs: HashMap::new(), + diffs_requested: HashSet::new(), + diff_list: cx.new(|_| right_panel::DiffList::new()), editor_pick: None, editor_menu_open: false, editor_menu, @@ -341,9 +358,14 @@ impl MainView { .update(cx, |view, cx| view.apply(&thread_id, event, cx)); return; } + if let Status::Diff { cwd, diff } = status { + self.set_diff(cwd, diff); + return cx.notify(); + } if matches!(status, Status::Connecting) { self.branches_requested.clear(); self.vcs.clear(); + self.diffs_requested.clear(); } self.status = status; // Thread activity reorders recency on every delta, so the default must not follow it. @@ -362,6 +384,7 @@ impl MainView { self.request_branch(); self.sync_vcs(); self.sync_terminal(cx); + self.sync_diff(); cx.notify(); } @@ -419,6 +442,7 @@ impl MainView { self.request_branch(); self.sync_vcs(); self.sync_terminal(cx); + self.sync_diff(); cx.notify(); } @@ -710,8 +734,11 @@ impl MainView { row.child(self.breadcrumb(active, title, cx)) .child(div().flex_1()) .when(self.details_cwd().is_some(), |row| { - row.child(self.terminal_toggle(cx)) - .child(self.details_toggle(cx)) + row.child(self.details_toggle(cx)) + .child(self.terminal_toggle(cx)) + }) + .when(self.right_panel_available(), |row| { + row.child(self.right_panel_toggle(cx)) }) }), ) @@ -806,12 +833,23 @@ impl MainView { gpui::rgb(0xef4444).into(), cx, )); - rows = rows.child(detail_row( + let changes = detail_row( Icons::FileDiff, "Changes".to_owned(), Some(counts.into_any_element()), cx, - )); + ); + rows = rows.child( + div() + .id("details-changes") + .rounded(px(6.)) + .when(self.selected_thread.is_some(), |row| { + row.cursor_pointer() + .hover(|style| style.bg(gpui::white().opacity(0.06))) + .on_click(cx.listener(|this, _, _, cx| this.open_diff(cx))) + }) + .child(changes), + ); } sections.push(rows.into_any_element()); } @@ -3708,8 +3746,9 @@ impl Render for MainView { let docked = self.sidebar_open && !narrow; let sidebar_width = clamp_sidebar_width(self.sidebar_width, self.brand_width.get(), width); let overlay = narrow && self.sidebar_overlay_open; - self.main_width - .set(width - if docked { sidebar_width } else { 0. }); + let container = width - if docked { sidebar_width } else { 0. }; + let panel = self.right_panel(width, container, cx); + self.main_width.set(container - panel.docked_width); v_flex() .relative() .size_full() @@ -3724,6 +3763,7 @@ impl Render for MainView { ) .on_action(cx.listener(|this, _: &OpenPermissions, _, cx| this.open_permissions(cx))) .on_action(cx.listener(|this, _: &ToggleDetails, _, cx| this.toggle_details(cx))) + .on_action(cx.listener(|this, _: &ToggleRightPanel, _, cx| this.toggle_right_panel(cx))) .on_action( cx.listener(|this, _: &ToggleTerminal, window, cx| { this.toggle_terminal(window, cx) @@ -3745,8 +3785,11 @@ impl Render for MainView { .when(docked, |row| { row.child(self.sidebar(sidebar_width, window, cx)) }) - .child(self.main_area(f32::from(window.viewport_size().height), cx)), + .child(self.main_area(f32::from(window.viewport_size().height), cx)) + .children(panel.column), ) + .children(panel.rail) + .children(panel.sheet) .when(docked, |root| { root.child(self.sidebar_rail(sidebar_width, cx)) }) diff --git a/apps/desktop-gpui/src/ui/right_panel.rs b/apps/desktop-gpui/src/ui/right_panel.rs new file mode 100644 index 000000000000..9b82d6bfb283 --- /dev/null +++ b/apps/desktop-gpui/src/ui/right_panel.rs @@ -0,0 +1,693 @@ +//! The web's right panel (`ChatRightPanel.tsx`, `RightPanelTabs.tsx`, `rightPanelStore.ts`) with +//! its Diff surface (`DiffPanel.tsx`). Only that surface is here; files, browser, device, pull +//! requests and side chats have no counterpart yet, so the tab strip lists Diff alone. + +use super::*; +use crate::diff::{self, Change, Kind, Preview}; +use gpui::{ScrollStrategy, UniformListScrollHandle, uniform_list}; +use std::{ops::Range, rc::Rc}; + +/// `usePreviewPanelInlineSize`: the default, minimum and cap of the docked panel's width, and +/// the width its sibling chat column keeps. +pub(super) const DEFAULT_WIDTH: f32 = 540.; +const MIN_WIDTH: f32 = 360.; +const MAX_WIDTH_FRACTION: f32 = 0.7; +const SIBLING_MIN_WIDTH: f32 = 360.; +/// `RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY`, `(max-width: 980px)`; narrower windows use a sheet. +const INLINE_BREAKPOINT: f32 = 980.; +/// `RightPanelSheet`: `w-[min(42vw,28rem)] min-w-80 max-w-[28rem]`, and below 760px +/// `w-[min(88vw,24rem)] min-w-0`. +const SHEET_MAX: f32 = 448.; +const SHEET_MIN: f32 = 320.; +const SHEET_COMPACT_BREAKPOINT: f32 = 760.; +const SHEET_COMPACT_MAX: f32 = 384.; +const RAIL_WIDTH: f32 = 16.; +/// The tab strip's `h-10`. +const STRIP_HEIGHT: f32 = 40.; +const ROW_HEIGHT: f32 = 24.; +const GUTTER_WIDTH: f32 = 44.; +const ADDITION: u32 = 0x22c55e; +const DELETION: u32 = 0xef4444; + +/// The web's default `rightPanel.toggle` binding on macOS. +pub const TOGGLE_SHORTCUT: &str = "\u{2325}\u{2318}B"; + +/// What the panel can show; the web's `RightPanelKind` minus the surfaces not ported. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Surface { + Diff, +} + +impl Surface { + fn label(self) -> &'static str { + match self { + Self::Diff => "Diff", + } + } + + fn icon(self) -> Icons { + match self { + Self::Diff => Icons::FileDiff, + } + } +} + +/// The docked width: at least the minimum, at most 70% of the window and what leaves the chat +/// column its own minimum. The cap never drops below the minimum. +fn clamp_width(width: f32, window_width: f32, container_width: f32) -> f32 { + let cap = (window_width * MAX_WIDTH_FRACTION) + .floor() + .min(container_width.floor() - SIBLING_MIN_WIDTH); + width.clamp(MIN_WIDTH, cap.max(MIN_WIDTH)) +} + +fn is_sheet(window_width: f32) -> bool { + window_width <= INLINE_BREAKPOINT +} + +fn sheet_width(window_width: f32) -> f32 { + if window_width <= SHEET_COMPACT_BREAKPOINT { + return (window_width * 0.88).min(SHEET_COMPACT_MAX); + } + (window_width * 0.42).clamp(SHEET_MIN, SHEET_MAX) +} + +#[derive(Clone)] +struct RightPanelResize; + +impl Render for RightPanelResize { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + +/// One line of the flattened diff. Every row has the same height so the list can be uniform. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Row { + File(usize), + Hunk(usize, usize), + Line(usize, usize, usize), + /// A file with nothing to list: binary, a rename, or a patch the server cut short. + Empty(usize), +} + +fn rows(preview: &Preview) -> Vec { + let mut rows = Vec::new(); + for (file_ix, file) in preview.files.iter().enumerate() { + rows.push(Row::File(file_ix)); + if file.hunks.is_empty() { + rows.push(Row::Empty(file_ix)); + } + for (hunk_ix, hunk) in file.hunks.iter().enumerate() { + rows.push(Row::Hunk(file_ix, hunk_ix)); + rows.extend((0..hunk.lines.len()).map(|line| Row::Line(file_ix, hunk_ix, line))); + } + } + rows +} + +/// What the Diff surface has to show for the thread's directory. +#[derive(Clone)] +pub enum Load { + Done(Rc), + Failed(String), +} + +/// The Diff surface's scrolling body: a summary, then the files with their hunks. +pub struct DiffList { + load: Option, + rows: Vec, + scroll: UniformListScrollHandle, +} + +impl DiffList { + pub fn new() -> Self { + Self { + load: None, + rows: Vec::new(), + scroll: UniformListScrollHandle::new(), + } + } + + /// Shows `load`, `None` while it is on its way. The same load again keeps the scroll place. + fn show(&mut self, load: Option<&Load>, cx: &mut Context) { + let same = match (&self.load, load) { + (None, None) => true, + (Some(Load::Done(a)), Some(Load::Done(b))) => Rc::ptr_eq(a, b), + (Some(Load::Failed(a)), Some(Load::Failed(b))) => a == b, + _ => false, + }; + if same { + return; + } + self.rows = match load { + Some(Load::Done(preview)) => rows(preview), + _ => Vec::new(), + }; + self.load = load.cloned(); + if !self.rows.is_empty() { + self.scroll.scroll_to_item(0, ScrollStrategy::Top); + } + cx.notify(); + } + + fn note(text: impl Into, cx: &App) -> gpui::Div { + div() + .flex_1() + .flex() + .items_center() + .justify_center() + .px_6() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(text.into()) + } + + fn summary(preview: &Preview, cx: &App) -> impl IntoElement { + let (additions, deletions) = preview.totals(); + let count = preview.files.len(); + let theme = cx.theme(); + h_flex() + .h_8() + .flex_shrink_0() + .px_3() + .gap_2() + .border_b_1() + .border_color(theme.border) + .text_xs() + .text_color(theme.muted_foreground) + .child(format!( + "{count} changed {}", + if count == 1 { "file" } else { "files" } + )) + .child(div().flex_1()) + .child(stat(additions, deletions)) + } + + fn row(&self, row: Row, cx: &App) -> AnyElement { + let Some(Load::Done(preview)) = &self.load else { + return div().into_any_element(); + }; + let theme = cx.theme(); + let base = h_flex().h(px(ROW_HEIGHT)).w_full().flex_shrink_0(); + match row { + Row::File(file_ix) => { + let file = &preview.files[file_ix]; + let (dir, name) = split_path(&file.path); + let mut label = h_flex().flex_1().min_w_0().overflow_hidden().gap_1(); + if let Some(previous) = &file.previous_path { + label = label.child( + div() + .text_color(theme.muted_foreground) + .child(format!("{previous} \u{2192}")), + ); + } + base.px_3() + .gap_2() + .bg(theme.secondary) + .border_y_1() + .border_color(theme.border) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .child( + label + .child( + div() + .whitespace_nowrap() + .text_color(theme.muted_foreground) + .child(dir.to_owned()), + ) + .child(div().whitespace_nowrap().child(name.to_owned())), + ) + .child(stat(file.additions, file.deletions)) + .into_any_element() + } + Row::Hunk(file_ix, hunk_ix) => base + .px_3() + .bg(theme.muted_foreground.opacity(0.06)) + .text_xs() + .font_family(theme.mono_font_family.clone()) + .text_color(theme.muted_foreground) + .child( + div() + .whitespace_nowrap() + .overflow_hidden() + .child(preview.files[file_ix].hunks[hunk_ix].header.clone()), + ) + .into_any_element(), + Row::Line(file_ix, hunk_ix, line_ix) => { + let line = &preview.files[file_ix].hunks[hunk_ix].lines[line_ix]; + let (tint, sign) = match line.kind { + Kind::Added => (Some(gpui::rgb(ADDITION)), "+"), + Kind::Removed => (Some(gpui::rgb(DELETION)), "-"), + Kind::Context | Kind::Note => (None, " "), + }; + let gutter = |number: Option| { + div() + .w(px(GUTTER_WIDTH)) + .flex_shrink_0() + .pr_2() + .text_right() + .text_color(theme.muted_foreground.opacity(0.7)) + .child(number.map(|n| n.to_string()).unwrap_or_default()) + }; + base.text_xs() + .font_family(theme.mono_font_family.clone()) + .when_some(tint, |row, color| row.bg(Hsla::from(color).opacity(0.12))) + .when(line.kind == Kind::Note, |row| { + row.text_color(theme.muted_foreground) + }) + .child(gutter(line.old)) + .child(gutter(line.new)) + .child( + div() + .w_4() + .flex_shrink_0() + .text_color(tint.map_or(theme.muted_foreground, Hsla::from)) + .child(sign), + ) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .child(line.text.replace('\t', " ")), + ) + .into_any_element() + } + Row::Empty(file_ix) => { + let file = &preview.files[file_ix]; + let text = if file.binary { + "Binary file not shown" + } else if file.additions + file.deletions > 0 { + "Too large to show in full; the counts include every change" + } else if file.change == Change::Renamed { + "Renamed without changes" + } else { + "No textual changes" + }; + base.px_3() + .text_xs() + .text_color(theme.muted_foreground) + .child(text) + .into_any_element() + } + } + } +} + +/// The web's `DiffStatLabel`: `+N` and `-N`, each tinted while non-zero. +fn stat(additions: u64, deletions: u64) -> impl IntoElement { + let count = |sign: char, count: u64, color: u32| { + div() + .font_family("monospace") + .opacity(if count == 0 { 0.5 } else { 1. }) + .text_color(gpui::rgb(color)) + .child(format!("{sign}{}", diff::format_count(count))) + }; + h_flex() + .flex_shrink_0() + .gap_1p5() + .text_xs() + .child(count('+', additions, ADDITION)) + .child(count('-', deletions, DELETION)) +} + +/// A path's directory with its trailing slash, and its file name. +fn split_path(path: &str) -> (&str, &str) { + path.rsplit_once('/').map_or(("", path), |(_, name)| { + (&path[..path.len() - name.len()], name) + }) +} + +impl Render for DiffList { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let body = match &self.load { + None => Self::note("Loading diff...", cx).into_any_element(), + Some(Load::Failed(message)) => { + Self::note(format!("Could not load the diff: {message}"), cx).into_any_element() + } + Some(Load::Done(preview)) if preview.files.is_empty() => { + Self::note("No changes in the working tree.", cx).into_any_element() + } + Some(Load::Done(preview)) => v_flex() + .flex_1() + .min_h_0() + .child(Self::summary(preview, cx)) + .when(preview.truncated, |column| { + column.child( + div() + .px_3() + .py_1() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("Partial diff preview. Counts include all changes."), + ) + }) + .child( + uniform_list( + "diff-rows", + self.rows.len(), + cx.processor(|this, range: Range, _, cx| { + range + .map(|ix| this.row(this.rows[ix], cx)) + .collect::>() + }), + ) + .track_scroll(&self.scroll) + .flex_1() + .min_h_0(), + ) + .into_any_element(), + }; + v_flex().size_full().min_h_0().child(body) + } +} + +/// The panel as laid out for one render. +pub struct Layout { + /// The docked column, inside the row under the top bar. + pub column: Option, + /// Its drag edge, over the window like the sidebar's rail. + pub rail: Option, + /// The sheet over the window when it is too narrow to dock. + pub sheet: Option, + /// The docked width, taken from the main area. + pub docked_width: f32, +} + +impl MainView { + /// The web's `rightPanelAvailable`: a thread with a workspace to diff. + pub(super) fn right_panel_available(&self) -> bool { + self.selected_thread.is_some() && self.details_cwd().is_some() + } + + fn right_panel_is_open(&self) -> bool { + self.right_panel_available() + && self + .details_key() + .is_some_and(|key| self.right_panel_open.contains(&key)) + } + + pub(super) fn toggle_right_panel(&mut self, cx: &mut Context) { + let Some(key) = self.details_key().filter(|_| self.right_panel_available()) else { + return; + }; + if !self.right_panel_open.remove(&key) { + self.right_panel_open.insert(key); + } + self.sync_diff(); + cx.notify(); + } + + /// The details card's Changes row: opens the panel on the Diff surface. + pub(super) fn open_diff(&mut self, cx: &mut Context) { + let Some(key) = self.details_key().filter(|_| self.right_panel_available()) else { + return; + }; + self.right_panel_open.insert(key); + self.sync_diff(); + cx.notify(); + } + + /// Asks for the diff of the open panel's directory once per connection. + pub(super) fn sync_diff(&mut self) { + if !self.right_panel_is_open() { + return; + } + let Some(cwd) = self.details_cwd() else { + return; + }; + if self.diffs_requested.insert(cwd.clone()) { + self.request_diff(cwd); + } + } + + fn request_diff(&self, cwd: String) { + if self.actions.try_send(Action::LoadDiff { cwd }).is_err() { + eprintln!("[ui] could not ask for the diff"); + } + } + + fn refresh_diff(&mut self, cx: &mut Context) { + let Some(cwd) = self.details_cwd() else { + return; + }; + self.diffs.remove(&cwd); + self.request_diff(cwd); + cx.notify(); + } + + pub(super) fn set_diff(&mut self, cwd: String, diff: Result) { + let load = match diff { + Ok(preview) => Load::Done(Rc::new(preview)), + Err(message) => Load::Failed(message), + }; + self.diffs.insert(cwd, load); + } + + /// The web's `PanelLayoutControls` right panel toggle, pressed while the panel is open. + pub(super) fn right_panel_toggle(&self, cx: &mut Context) -> impl IntoElement { + let pressed = self.right_panel_is_open(); + let foreground = cx.theme().foreground; + header_icon_button( + "toggle-right-panel", + Icons::PanelRight, + format!("Toggle right panel ({TOGGLE_SHORTCUT})").into(), + cx, + ) + .when(pressed, move |button| { + button.bg(foreground.opacity(0.06)).text_color(foreground) + }) + .on_click(cx.listener(|this, _, _, cx| this.toggle_right_panel(cx))) + } + + /// Docked beside the main area, or as a sheet over it in narrow windows. + pub(super) fn right_panel( + &mut self, + window_width: f32, + container_width: f32, + cx: &mut Context, + ) -> Layout { + let none = Layout { + column: None, + rail: None, + sheet: None, + docked_width: 0., + }; + if !self.right_panel_is_open() { + return none; + } + let load = self + .details_cwd() + .and_then(|cwd| self.diffs.get(&cwd).cloned()); + self.diff_list + .update(cx, |list, cx| list.show(load.as_ref(), cx)); + if is_sheet(window_width) { + let width = sheet_width(window_width); + return Layout { + sheet: Some(self.right_panel_sheet(width, cx)), + ..none + }; + } + let width = clamp_width(self.right_panel_width, window_width, container_width); + Layout { + column: Some(self.right_panel_body(width, false, cx).into_any_element()), + rail: Some(self.right_panel_rail(width, cx).into_any_element()), + sheet: None, + docked_width: width, + } + } + + /// The tab strip over the active surface. + fn right_panel_body(&self, width: f32, sheet: bool, cx: &mut Context) -> gpui::Div { + let theme = cx.theme(); + let (border, foreground) = (theme.border, theme.foreground); + let surface = Surface::Diff; + let tab = h_flex() + .h_6() + .flex_shrink_0() + .gap_1p5() + .pl_1p5() + .pr_2() + .rounded(px(6.)) + .bg(foreground.opacity(0.06)) + .text_xs() + .text_color(foreground) + .child( + Icon::new(surface.icon()) + .size(px(12.)) + .text_color(theme.muted_foreground), + ) + .child(surface.label()); + let strip = h_flex() + .h(px(STRIP_HEIGHT)) + .flex_shrink_0() + .px_2() + .gap_1() + .border_b_1() + .border_color(border) + .child(tab) + .child(div().flex_1()) + .child( + header_icon_button("refresh-diff", Icons::RefreshCw, "Refresh diff".into(), cx) + .on_click(cx.listener(|this, _, _, cx| this.refresh_diff(cx))), + ) + .when(sheet, |strip| { + strip.child( + ghost_icon_button( + "close-right-panel", + Icon::new(IconName::Close).size_4(), + "Close right panel".into(), + true, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| this.toggle_right_panel(cx))), + ) + }); + v_flex() + .w(px(width)) + .h_full() + .flex_shrink_0() + .min_w_0() + .bg(cx.theme().background) + .when(!sheet, |panel| panel.border_l_1().border_color(border)) + .child(strip) + .child(div().flex_1().min_h_0().child(self.diff_list.clone())) + } + + /// The panel's `DiffPanelShell` as a sheet: a dimmed backdrop and a drawer from the right. + fn right_panel_sheet(&self, width: f32, cx: &mut Context) -> AnyElement { + let drawer = div() + .absolute() + .top_0() + .bottom_0() + .right_0() + .w(px(width)) + .occlude() + .shadow_lg() + .child(self.right_panel_body(width, true, cx)) + .with_animation( + "right-panel-sheet", + Animation::new(Duration::from_millis(SHEET_MS)).with_easing(ease_in_out), + |drawer, delta| drawer.right(px(-SHEET_SLIDE * (1. - delta))).opacity(delta), + ); + div() + .absolute() + .inset_0() + .child( + div() + .id("right-panel-backdrop") + .absolute() + .inset_0() + .cursor_default() + .bg(cx.theme().background.opacity(0.6)) + .on_click(cx.listener(|this, _, _, cx| this.toggle_right_panel(cx))), + ) + .child(drawer) + .into_any_element() + } + + /// Drag the panel's left edge to resize, double-click to reset, like the sidebar's rail. + fn right_panel_rail(&self, width: f32, cx: &mut Context) -> impl IntoElement { + let line = cx.theme().border; + h_flex() + .id("right-panel-rail") + .group("right-panel-rail") + .absolute() + .top(px(TOPBAR_HEIGHT)) + .bottom_0() + .right(px(width - RAIL_WIDTH / 2.)) + .w(px(RAIL_WIDTH)) + .justify_center() + .occlude() + .cursor_col_resize() + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, event: &MouseDownEvent, _, cx| { + if event.click_count == 2 { + this.right_panel_width = DEFAULT_WIDTH; + cx.notify(); + } + }), + ) + .on_drag(RightPanelResize, |drag, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| drag.clone()) + }) + .on_drag_move(cx.listener( + |this, event: &DragMoveEvent, window, cx| { + let viewport = f32::from(window.viewport_size().width); + let wanted = viewport - f32::from(event.event.position.x); + let sidebar = if this.sidebar_open { + this.rendered_sidebar_width.get() + } else { + 0. + }; + this.right_panel_width = clamp_width(wanted, viewport, viewport - sidebar); + cx.notify(); + }, + )) + .child( + div() + .h_full() + .w(px(2.)) + .group_hover("right-panel-rail", move |style| style.bg(line)), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::diff::parse; + + #[test] + fn the_width_stays_between_the_minimum_and_what_the_chat_column_leaves() { + assert_eq!(clamp_width(100., 1600., 1344.), MIN_WIDTH); + assert_eq!(clamp_width(540., 1600., 1344.), 540.); + assert_eq!(clamp_width(2000., 1600., 1344.), 984.); + assert_eq!(clamp_width(2000., 2000., 1400.), 1040.); + // The sibling's minimum wins over the fraction, and the minimum over both. + assert_eq!(clamp_width(2000., 1000., 700.), MIN_WIDTH); + } + + #[test] + fn narrow_windows_use_a_sheet_sized_like_the_webs() { + assert!(is_sheet(980.) && !is_sheet(981.)); + assert_eq!(sheet_width(900.), 378.); + assert_eq!(sheet_width(2000.), SHEET_MAX); + assert_eq!(sheet_width(761.), SHEET_MIN); + assert_eq!(sheet_width(700.), SHEET_COMPACT_MAX); + assert_eq!(sheet_width(400.), 352.); + } + + #[test] + fn rows_list_each_file_then_its_hunks_and_lines() { + let files = parse( + "diff --git a/a b/a\n--- a/a\n+++ b/a\n@@ -1 +1 @@\n-x\n+y\ndiff --git a/b b/b\nBinary files a/b and b/b differ\n", + ); + let preview = Preview { + files, + truncated: false, + }; + assert_eq!( + rows(&preview), + [ + Row::File(0), + Row::Hunk(0, 0), + Row::Line(0, 0, 0), + Row::Line(0, 0, 1), + Row::File(1), + Row::Empty(1), + ] + ); + } + + #[test] + fn a_path_splits_into_directory_and_name() { + assert_eq!(split_path("src/ui/mod.rs"), ("src/ui/", "mod.rs")); + assert_eq!(split_path("Cargo.toml"), ("", "Cargo.toml")); + } +} From 9da288049ea6516677fe31bde3cba360d0ba2c53 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 12:57:36 -0500 Subject: [PATCH 30/82] feat(desktop-gpui): details card logic for git actions, ref picker and usage limits --- apps/desktop-gpui/src/details.rs | 51 ++- apps/desktop-gpui/src/git_actions.rs | 501 +++++++++++++++++++++++++++ apps/desktop-gpui/src/main.rs | 3 + apps/desktop-gpui/src/refs.rs | 301 ++++++++++++++++ apps/desktop-gpui/src/usage.rs | 326 +++++++++++++++++ 5 files changed, 1179 insertions(+), 3 deletions(-) create mode 100644 apps/desktop-gpui/src/git_actions.rs create mode 100644 apps/desktop-gpui/src/refs.rs create mode 100644 apps/desktop-gpui/src/usage.rs diff --git a/apps/desktop-gpui/src/details.rs b/apps/desktop-gpui/src/details.rs index 0031dadcd6c7..4fb01107f149 100644 --- a/apps/desktop-gpui/src/details.rs +++ b/apps/desktop-gpui/src/details.rs @@ -191,6 +191,12 @@ impl Lines { } } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ChangedFile { + pub path: String, + pub lines: Lines, +} + /// The local half of the `subscribeVcsStatus` stream (`VcsStatusLocalResult`). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Local { @@ -201,6 +207,12 @@ pub struct Local { pub has_working_tree_changes: bool, pub working_tree: Lines, pub branch_changes: Option, + /// The base the branch totals compare against (`branchChanges.baseRef`). + pub base_ref: Option, + /// `workingTree.files`, which the commit dialog lists. + pub files: Vec, + /// `sourceControlProvider.kind`, which picks PR or MR wording. + pub provider_kind: Option, } /// The remote half (`VcsStatusRemoteResult`); absent until the server has looked, which reads as @@ -211,7 +223,21 @@ pub struct Remote { pub ahead: u64, pub behind: u64, pub ahead_of_default: Option, - pub pr_open: bool, + pub pr: Option, +} + +/// The change request `pr` carries; only its state decides the actions. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PullRequest { + pub number: u64, + pub url: String, + pub open: bool, +} + +impl Remote { + pub fn pr_open(&self) -> bool { + self.pr.as_ref().is_some_and(|pr| pr.open) + } } #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -234,6 +260,21 @@ impl Local { .get("branchChanges") .filter(|changes| changes.is_object()) .map(Lines::decode), + files: value["workingTree"]["files"] + .as_array() + .into_iter() + .flatten() + .filter_map(|file| { + Some(ChangedFile { + path: file["path"].as_str()?.to_owned(), + lines: Lines::decode(file), + }) + }) + .collect(), + base_ref: value["branchChanges"]["baseRef"].as_str().map(str::to_owned), + provider_kind: value["sourceControlProvider"]["kind"] + .as_str() + .map(str::to_owned), }) } } @@ -248,7 +289,11 @@ impl Remote { ahead: value["aheadCount"].as_u64().unwrap_or(0), behind: value["behindCount"].as_u64().unwrap_or(0), ahead_of_default: value["aheadOfDefaultCount"].as_u64(), - pr_open: value["pr"]["state"] == "open", + pr: value["pr"].is_object().then(|| PullRequest { + number: value["pr"]["number"].as_u64().unwrap_or(0), + url: value["pr"]["url"].as_str().unwrap_or_default().to_owned(), + open: value["pr"]["state"] == "open", + }), } } } @@ -317,7 +362,7 @@ impl VcsStatus { if remote.behind > 0 || remote.ahead > 0 { return false; } - if remote.pr_open { + if remote.pr_open() { return true; } let delta = remote.ahead_of_default.unwrap_or(remote.ahead) > 0; diff --git a/apps/desktop-gpui/src/git_actions.rs b/apps/desktop-gpui/src/git_actions.rs new file mode 100644 index 000000000000..82d4f0d48d34 --- /dev/null +++ b/apps/desktop-gpui/src/git_actions.rs @@ -0,0 +1,501 @@ +//! The git action controls' logic: `GitActionsControl.logic.ts`. Which actions the menu offers, +//! what the quick action button says and does, and the default ref confirmation wording. + +use crate::details::VcsStatus; + +/// `GitStackedAction`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Action { + Commit, + CommitPush, + CommitPushPr, + Push, + CreatePr, +} + +impl Action { + pub fn wire(self) -> &'static str { + match self { + Self::Commit => "commit", + Self::CommitPush => "commit_push", + Self::CommitPushPr => "commit_push_pr", + Self::Push => "push", + Self::CreatePr => "create_pr", + } + } +} + +/// `ChangeRequestTerminology`: GitLab says merge request, every other provider pull request. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Terminology { + pub short: &'static str, + pub singular: &'static str, +} + +impl Terminology { + pub fn of(status: Option<&VcsStatus>) -> Self { + let kind = status.and_then(|status| status.local.provider_kind.as_deref()); + if kind == Some("gitlab") { + Self { + short: "MR", + singular: "merge request", + } + } else { + Self { + short: "PR", + singular: "pull request", + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MenuId { + Commit, + Push, + Pr, +} + +/// `GitActionMenuItem`; each opens a dialog or runs after the default ref confirmation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MenuItem { + pub id: MenuId, + pub label: String, + pub disabled: bool, +} + +pub fn build_menu_items( + status: Option<&VcsStatus>, + is_busy: bool, + has_primary_remote: bool, +) -> Vec { + let Some(status) = status else { + return Vec::new(); + }; + let (local, remote) = (&status.local, &status.remote); + let terminology = Terminology::of(Some(status)); + let has_branch = local.ref_name.is_some(); + let has_open_pr = remote.pr_open(); + let is_behind = remote.behind > 0; + let has_default_delta = remote.ahead_of_default.unwrap_or(remote.ahead) > 0; + let can_push_without_upstream = has_primary_remote && !remote.has_upstream; + let can_publish = remote.has_upstream || can_push_without_upstream; + let can_commit = !is_busy && local.has_working_tree_changes; + let can_push = !is_busy && has_branch && !is_behind && remote.ahead > 0 && can_publish; + let can_create_pr = + !is_busy && has_branch && !has_open_pr && has_default_delta && !is_behind && can_publish; + + let commit = MenuItem { + id: MenuId::Commit, + label: "Commit".to_owned(), + disabled: !can_commit, + }; + if !has_primary_remote { + return vec![commit]; + } + let push = MenuItem { + id: MenuId::Push, + label: "Push".to_owned(), + disabled: !can_push, + }; + if has_open_pr { + return vec![commit, push]; + } + vec![ + commit, + push, + MenuItem { + id: MenuId::Pr, + label: format!("Create {}", terminology.short), + disabled: !can_create_pr, + }, + ] +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum QuickKind { + Run(Action), + Pull, + Publish, + Hint, +} + +/// `GitQuickAction`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Quick { + pub label: String, + pub disabled: bool, + pub kind: QuickKind, + pub hint: Option, +} + +impl Quick { + fn run(label: impl Into, action: Action) -> Self { + Self { + label: label.into(), + disabled: false, + kind: QuickKind::Run(action), + hint: None, + } + } + + fn hint(label: &str, hint: impl Into) -> Self { + Self { + label: label.to_owned(), + disabled: true, + kind: QuickKind::Hint, + hint: Some(hint.into()), + } + } +} + +pub fn resolve_quick_action( + status: Option<&VcsStatus>, + is_busy: bool, + is_default_ref: bool, + has_primary_remote: bool, +) -> Quick { + if is_busy { + return Quick::hint("Commit", "Git action in progress."); + } + let Some(status) = status else { + return Quick::hint("Commit", "Git status is unavailable."); + }; + let (local, remote) = (&status.local, &status.remote); + let terminology = Terminology::of(Some(status)); + let short = terminology.short; + let is_ahead = remote.ahead > 0; + let is_behind = remote.behind > 0; + let has_open_pr = remote.pr_open(); + let has_default_delta = remote.ahead_of_default.unwrap_or(remote.ahead) > 0; + let push_action = if is_default_ref { + Action::CommitPush + } else { + Action::Push + }; + + if local.ref_name.is_none() { + return Quick::hint( + "Commit", + format!( + "Create and checkout a ref before pushing or opening a {}.", + terminology.singular + ), + ); + } + if local.has_working_tree_changes { + if is_behind || (!remote.has_upstream && !has_primary_remote) { + return Quick::run("Commit", Action::Commit); + } + if has_open_pr || is_default_ref { + return Quick::run("Commit & push", Action::CommitPush); + } + return Quick::run(format!("Commit, push & {short}"), Action::CommitPushPr); + } + if !remote.has_upstream { + if !has_primary_remote { + return Quick { + label: "Publish repository".to_owned(), + disabled: false, + kind: QuickKind::Publish, + hint: None, + }; + } + if !is_ahead { + return if has_open_pr { + Quick::hint("Commit", "Branch is up to date. No action needed.") + } else { + Quick::hint("Push", "No local commits to push.") + }; + } + if has_open_pr || is_default_ref { + return Quick::run("Push", push_action); + } + return Quick::run(format!("Push & create {short}"), Action::CreatePr); + } + if is_ahead && is_behind { + return Quick::hint( + "Sync ref", + "Branch has diverged from upstream. Rebase/merge first.", + ); + } + if is_behind { + return Quick { + label: "Pull".to_owned(), + disabled: false, + kind: QuickKind::Pull, + hint: None, + }; + } + if is_ahead { + if has_open_pr || is_default_ref { + return Quick::run("Push", push_action); + } + return Quick::run(format!("Push & create {short}"), Action::CreatePr); + } + if has_open_pr { + return Quick::hint("Commit", "Branch is up to date. No action needed."); + } + if has_default_delta && !is_default_ref { + return Quick::run(format!("Create {short}"), Action::CreatePr); + } + Quick::hint("Commit", "Branch is up to date. No action needed.") +} + +/// `requiresDefaultBranchConfirmation`. +pub fn requires_default_branch_confirmation(action: Action, is_default_ref: bool) -> bool { + is_default_ref && action != Action::Commit +} + +/// `DefaultBranchActionDialogCopy`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DialogCopy { + pub title: String, + pub description: String, + pub continue_label: String, +} + +/// `resolveDefaultBranchActionDialogCopy`; `action` is one `requires_default_branch_confirmation` allows. +pub fn default_branch_dialog_copy( + action: Action, + branch: &str, + includes_commit: bool, + terminology: Terminology, +) -> DialogCopy { + let suffix = format!( + " on \"{branch}\". You can continue on this ref or create a feature ref and run the same action there." + ); + if matches!(action, Action::Push | Action::CommitPush) { + return if includes_commit { + DialogCopy { + title: "Commit & push to default ref?".to_owned(), + description: format!("This action will commit and push changes{suffix}"), + continue_label: format!("Commit & push to {branch}"), + } + } else { + DialogCopy { + title: "Push to default ref?".to_owned(), + description: format!("This action will push local commits{suffix}"), + continue_label: format!("Push to {branch}"), + } + }; + } + let (short, singular) = (terminology.short, terminology.singular); + if includes_commit { + DialogCopy { + title: format!("Commit, push & create {short} from default ref?"), + description: format!("This action will commit, push, and create a {singular}{suffix}"), + continue_label: format!("Commit, push & create {short}"), + } + } else { + DialogCopy { + title: format!("Push & create {short} from default ref?"), + description: format!( + "This action will push local commits and create a {singular}{suffix}" + ), + continue_label: format!("Push & create {short}"), + } + } +} + +/// `resolveFeatureBranchUnavailableReason`: clean trees cannot name a feature branch. +pub fn feature_branch_unavailable_reason(branch: &str, has_changes: bool) -> Option { + (!has_changes).then(|| { + format!( + "There are no changes to commit, so a feature branch can't be named. Create a branch yourself or continue on {branch}." + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::{Value, json}; + + fn status(local: Value, remote: Value) -> VcsStatus { + let mut base = json!({ + "isRepo": true, "hasPrimaryRemote": true, "isDefaultRef": false, + "refName": "feature/test", "hasWorkingTreeChanges": false, + "workingTree": {"files": [], "insertions": 0, "deletions": 0}, + }); + let mut tail = json!({"hasUpstream": true, "aheadCount": 0, "behindCount": 0, "pr": null}); + for (target, patch) in [(&mut base, local), (&mut tail, remote)] { + if let Some(patch) = patch.as_object() { + for (key, value) in patch { + target[key] = value.clone(); + } + } + } + VcsStatus::apply( + None, + &json!({"_tag": "snapshot", "local": base, "remote": tail}), + ) + .expect("status") + } + + fn open_pr() -> Value { + json!({"number": 13, "title": "Open PR", "url": "https://example.com/pr/13", + "baseRef": "main", "headRef": "feature/test", "state": "open"}) + } + + fn quick(status: &VcsStatus) -> Quick { + resolve_quick_action(Some(status), false, false, true) + } + + fn pr_item(items: &[MenuItem]) -> &MenuItem { + items.iter().find(|item| item.id == MenuId::Pr).expect("pr item") + } + + #[test] + fn a_clean_ahead_ref_with_an_open_pr_prefers_push() { + let ahead = status(json!({}), json!({"aheadCount": 3, "pr": open_pr()})); + assert_eq!(quick(&ahead), Quick::run("Push", Action::Push)); + } + + #[test] + fn a_synced_ref_ahead_of_default_creates_a_pr() { + let synced = status(json!({}), json!({"aheadOfDefaultCount": 1})); + assert_eq!(quick(&synced), Quick::run("Create PR", Action::CreatePr)); + assert!(!pr_item(&build_menu_items(Some(&synced), false, true)).disabled); + let dirty = status( + json!({"hasWorkingTreeChanges": true}), + json!({"aheadOfDefaultCount": 1}), + ); + assert!(!pr_item(&build_menu_items(Some(&dirty), false, true)).disabled); + } + + #[test] + fn the_default_ref_commits_and_pushes() { + let dirty = status( + json!({"refName": "main", "hasWorkingTreeChanges": true}), + json!({}), + ); + let quick = resolve_quick_action(Some(&dirty), false, true, true); + assert_eq!(quick, Quick::run("Commit & push", Action::CommitPush)); + } + + #[test] + fn dirty_changes_behind_upstream_only_commit() { + let behind = status( + json!({"hasWorkingTreeChanges": true}), + json!({"behindCount": 2}), + ); + let commit = Quick::run("Commit", Action::Commit); + assert_eq!(quick(&behind), commit); + let with_pr = status( + json!({"hasWorkingTreeChanges": true}), + json!({"behindCount": 2, "pr": open_pr()}), + ); + assert_eq!(quick(&with_pr), commit); + } + + #[test] + fn no_upstream_pushes_and_creates_or_publishes() { + let ahead = status(json!({}), json!({"hasUpstream": false, "aheadCount": 2})); + assert_eq!( + quick(&ahead), + Quick::run("Push & create PR", Action::CreatePr) + ); + let publish = resolve_quick_action(Some(&ahead), false, false, false); + assert_eq!(publish.kind, QuickKind::Publish); + assert_eq!(publish.label, "Publish repository"); + assert!(!publish.disabled); + } + + #[test] + fn hints_explain_each_disabled_state() { + let synced = status(json!({}), json!({})); + assert_eq!( + quick(&synced).hint.as_deref(), + Some("Branch is up to date. No action needed.") + ); + let diverged = status(json!({}), json!({"aheadCount": 1, "behindCount": 1})); + assert_eq!(quick(&diverged).label, "Sync ref"); + let behind = status(json!({}), json!({"behindCount": 1})); + assert_eq!(quick(&behind).kind, QuickKind::Pull); + let detached = status(json!({"refName": null}), json!({})); + let hint = quick(&detached).hint.expect("hint"); + assert!(hint.starts_with("Create and checkout a ref")); + let busy = resolve_quick_action(Some(&synced), true, false, true); + assert_eq!(busy.hint.as_deref(), Some("Git action in progress.")); + let none = resolve_quick_action(None, false, false, true); + assert_eq!(none.hint.as_deref(), Some("Git status is unavailable.")); + let fresh = status(json!({}), json!({"hasUpstream": false})); + assert_eq!( + quick(&fresh).hint.as_deref(), + Some("No local commits to push.") + ); + } + + #[test] + fn the_menu_follows_remote_and_pr_state() { + let ahead = status(json!({}), json!({"aheadCount": 1})); + let ids = |items: &[MenuItem]| items.iter().map(|item| item.id).collect::>(); + let full = build_menu_items(Some(&ahead), false, true); + assert_eq!(ids(&full), [MenuId::Commit, MenuId::Push, MenuId::Pr]); + assert!(full[0].disabled && !full[1].disabled); + let no_remote = build_menu_items(Some(&ahead), false, false); + assert_eq!(ids(&no_remote), [MenuId::Commit]); + let with_pr = status(json!({}), json!({"aheadCount": 1, "pr": open_pr()})); + assert_eq!( + ids(&build_menu_items(Some(&with_pr), false, true)), + [MenuId::Commit, MenuId::Push] + ); + let busy = build_menu_items(Some(&ahead), true, true); + assert!(busy.iter().all(|item| item.disabled)); + assert!(build_menu_items(None, false, true).is_empty()); + } + + #[test] + fn gitlab_says_merge_request() { + let provider = json!({"kind": "gitlab", "name": "GitLab"}); + let ahead = status( + json!({"sourceControlProvider": provider}), + json!({"hasUpstream": false, "aheadCount": 2}), + ); + assert_eq!(quick(&ahead).label, "Push & create MR"); + let dirty = status( + json!({"sourceControlProvider": provider, "hasWorkingTreeChanges": true}), + json!({}), + ); + assert_eq!(quick(&dirty).label, "Commit, push & MR"); + assert_eq!(pr_item(&build_menu_items(Some(&ahead), false, true)).label, "Create MR"); + let copy = default_branch_dialog_copy( + Action::CreatePr, + "main", + false, + Terminology::of(Some(&ahead)), + ); + assert_eq!(copy.title, "Push & create MR from default ref?"); + assert!(copy.description.contains("create a merge request on")); + } + + #[test] + fn the_default_ref_confirms_every_push_action() { + assert!(!requires_default_branch_confirmation(Action::Commit, true)); + for action in [ + Action::Push, + Action::CreatePr, + Action::CommitPush, + Action::CommitPushPr, + ] { + assert!(requires_default_branch_confirmation(action, true)); + assert!(!requires_default_branch_confirmation(action, false)); + } + } + + #[test] + fn the_confirmation_names_the_branch() { + let pr = Terminology::of(None); + let copy = default_branch_dialog_copy(Action::CommitPush, "main", true, pr); + assert_eq!(copy.title, "Commit & push to default ref?"); + assert_eq!(copy.continue_label, "Commit & push to main"); + let copy = default_branch_dialog_copy(Action::Push, "main", false, pr); + assert_eq!(copy.title, "Push to default ref?"); + let copy = default_branch_dialog_copy(Action::CommitPushPr, "main", true, pr); + assert_eq!(copy.continue_label, "Commit, push & create PR"); + assert!(feature_branch_unavailable_reason("main", true).is_none()); + let reason = feature_branch_unavailable_reason("main", false).expect("reason"); + assert!(reason.ends_with("continue on main.")); + } +} diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index bf5766bccc2d..0f9681d6f7d3 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -7,10 +7,12 @@ mod details; mod diff; mod electron_dpop; mod environments; +mod git_actions; mod history; mod minimap; mod projects; mod providers; +mod refs; mod rpc; mod server; mod shell; @@ -22,6 +24,7 @@ mod terminal_view; mod thread; mod thread_card; mod ui; +mod usage; mod wire; use assets::AppAssets; diff --git a/apps/desktop-gpui/src/refs.rs b/apps/desktop-gpui/src/refs.rs new file mode 100644 index 000000000000..f7ae278be9a4 --- /dev/null +++ b/apps/desktop-gpui/src/refs.rs @@ -0,0 +1,301 @@ +//! The branch ref picker of the thread details card: `BranchPicker.tsx` and +//! `BranchToolbar.logic.ts` over `vcs.listRefs`, with `splitForMiddleTruncate` from +//! `ui/middle-truncate.tsx`. + +use serde_json::Value; + +/// One `VcsRef` of a `vcs.listRefs` page. +#[derive(Clone, Debug, PartialEq)] +pub struct Ref { + pub name: String, + pub is_remote: bool, + pub current: bool, + pub is_default: bool, + pub worktree_path: Option, +} + +/// The right-side tag of a picker row. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tag { + Current, + Worktree, + Remote, + Default, +} + +impl Tag { + pub fn label(self) -> &'static str { + match self { + Self::Current => "current", + Self::Worktree => "worktree", + Self::Remote => "remote", + Self::Default => "default", + } + } +} + +/// One page of refs and the counts the footer reports. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Page { + pub refs: Vec, + pub total: usize, + pub has_more: bool, +} + +impl Page { + pub fn decode(value: &Value) -> Self { + let refs: Vec = value["refs"] + .as_array() + .into_iter() + .flatten() + .filter_map(|entry| { + Some(Ref { + name: entry["name"].as_str()?.to_owned(), + is_remote: entry["isRemote"].as_bool().unwrap_or(false), + current: entry["current"].as_bool().unwrap_or(false), + is_default: entry["isDefault"].as_bool().unwrap_or(false), + worktree_path: entry["worktreePath"].as_str().map(str::to_owned), + }) + }) + .collect(); + let total = value["totalCount"] + .as_u64() + .map_or(refs.len(), |count| count as usize); + Self { + has_more: !value["nextCursor"].is_null(), + refs, + total, + } + } + + /// The picker's footer: the loading states, or how much of the list is shown. + pub fn status(&self, loading: bool) -> Option { + if loading { + return Some(if self.refs.is_empty() { + "Loading refs...".to_owned() + } else { + "Loading more refs...".to_owned() + }); + } + self.has_more + .then(|| format!("Showing {} of {} refs", self.refs.len(), self.total)) + } +} + +/// The one tag a row shows. A worktree only counts when it is not the project's own checkout. +pub fn tag(entry: &Ref, project_cwd: &str) -> Option { + if entry.current { + Some(Tag::Current) + } else if entry + .worktree_path + .as_deref() + .is_some_and(|path| path != project_cwd) + { + Some(Tag::Worktree) + } else if entry.is_remote { + Some(Tag::Remote) + } else if entry.is_default { + Some(Tag::Default) + } else { + None + } +} + +/// `sanitizeNewRefName`: spaces in a typed name become dashes. +pub fn sanitize_new_ref_name(name: &str) -> String { + name.split_whitespace().collect::>().join("-") +} + +/// `deriveLocalBranchNameFromRemoteRef`: `origin/feature/x` is the local `feature/x`. +pub fn local_name_of_remote(name: &str) -> &str { + name.split_once('/').map_or(name, |(_, rest)| rest) +} + +/// `shouldIncludeBranchPickerItem` for a ref: the query matches as typed or as a ref name. +pub fn matches_query(name: &str, query: &str) -> bool { + let query = query.trim().to_lowercase(); + if query.is_empty() { + return true; + } + let name = name.to_lowercase(); + name.contains(&query) || name.contains(&sanitize_new_ref_name(&query)) +} + +const DEFAULT_TAIL: usize = 10; +const MAX_SEGMENT_TAIL: usize = 16; +const MIN_WORD_TAIL: usize = 4; + +fn is_word_break(ch: char) -> bool { + matches!(ch, '-' | '_' | '.' | '/') +} + +/// `splitForMiddleTruncate`: the head that may be cut and the tail that stays, or `None` for a +/// value too short to truncate. +pub fn split_for_middle_truncate(value: &str, tail: Option) -> Option<(String, String)> { + let chars: Vec = value.chars().collect(); + let len = chars.len(); + let mut keep = tail.unwrap_or(DEFAULT_TAIL); + if tail.is_none() + && let Some(slash) = chars.iter().rposition(|ch| *ch == '/') + && slash > 0 + && slash < len - 1 + { + let segment = len - slash - 1; + keep = if segment <= MAX_SEGMENT_TAIL { + segment + } else { + DEFAULT_TAIL + }; + } + if keep == 0 || len <= keep + 4 { + return None; + } + let mut cut = len - keep; + if tail.is_none() && !is_word_break(chars[cut - 1]) { + let starts_word = |index: usize| is_word_break(chars[index - 1]); + let mut snapped = (cut + 1..=len - MIN_WORD_TAIL).find(|index| starts_word(*index)); + if snapped.is_none() { + snapped = (5..cut) + .rev() + .take_while(|index| *index + MAX_SEGMENT_TAIL >= len) + .find(|index| starts_word(*index)); + } + if let Some(index) = snapped.filter(|index| *index > 0) { + cut = index; + } + } + Some((chars[..cut].iter().collect(), chars[cut..].iter().collect())) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn split(value: &str, tail: Option) -> Option<(String, String)> { + split_for_middle_truncate(value, tail) + } + + fn tail_of(value: &str) -> String { + split(value, None).expect("split").1 + } + + #[test] + fn short_values_are_left_whole() { + assert_eq!(split("main", None), None); + assert_eq!(split("feature", None), None); + } + + #[test] + fn the_last_path_segment_stays() { + assert_eq!( + split("feature/ui-polish", None), + Some(("feature/".to_owned(), "ui-polish".to_owned())) + ); + } + + #[test] + fn automatic_tails_start_on_a_word() { + assert_eq!(tail_of("t3/clone-repo-to-other-machine"), "machine"); + assert_eq!(tail_of("t3/investigate-release-build-times"), "times"); + assert_eq!(tail_of("t3/compare-long-featurenames-ab"), "featurenames-ab"); + assert_eq!(tail_of("t3/abcdefghijklmnopqrstuvwxyz"), "qrstuvwxyz"); + } + + #[test] + fn an_explicit_tail_is_exact() { + assert_eq!( + split("t3/clone-repo-to-other-machine", Some(6)).map(|pair| pair.1), + Some("achine".to_owned()) + ); + } + + #[test] + fn truncation_counts_characters() { + let value = "ramas/ñandú-ñandú-ñandú-ñandú"; + let (head, tail) = split(value, Some(5)).expect("split"); + assert_eq!(format!("{head}{tail}"), value); + assert_eq!(tail.chars().count(), 5); + } + + #[test] + fn tags_follow_the_web_priority() { + let base = Ref { + name: "main".to_owned(), + is_remote: false, + current: false, + is_default: false, + worktree_path: None, + }; + let with = |edit: fn(&mut Ref)| { + let mut entry = base.clone(); + edit(&mut entry); + tag(&entry, "/repo") + }; + assert_eq!(with(|_| {}), None); + assert_eq!(with(|r| r.is_default = true), Some(Tag::Default)); + assert_eq!( + with(|r| { + r.is_default = true; + r.is_remote = true; + }), + Some(Tag::Remote) + ); + assert_eq!( + with(|r| { + r.is_remote = true; + r.worktree_path = Some("/elsewhere".to_owned()); + }), + Some(Tag::Worktree) + ); + assert_eq!( + with(|r| r.worktree_path = Some("/repo".to_owned())), + None + ); + assert_eq!( + with(|r| { + r.current = true; + r.worktree_path = Some("/elsewhere".to_owned()); + }), + Some(Tag::Current) + ); + } + + #[test] + fn queries_match_as_typed_or_sanitized() { + assert!(matches_query("feature/Ui-Polish", "ui-pol")); + assert!(matches_query("feature/ui-polish", "ui polish")); + assert!(matches_query("anything", " ")); + assert!(!matches_query("main", "dev")); + } + + #[test] + fn names_derive_from_remote_refs() { + assert_eq!(local_name_of_remote("origin/feature/x"), "feature/x"); + assert_eq!(local_name_of_remote("main"), "main"); + assert_eq!(sanitize_new_ref_name(" my new branch "), "my-new-branch"); + } + + #[test] + fn pages_decode_and_report_their_footer() { + let page = Page::decode(&json!({ + "refs": [ + {"name": "main", "current": true, "isDefault": true, "worktreePath": null}, + {"name": "origin/main", "isRemote": true, "current": false, "isDefault": false}, + {"broken": true}, + ], + "nextCursor": "2", + "totalCount": 768, + "isRepo": true, + "hasPrimaryRemote": true, + })); + assert_eq!(page.refs.len(), 2); + assert_eq!(page.total, 768); + assert_eq!(page.status(false).as_deref(), Some("Showing 2 of 768 refs")); + assert_eq!(page.status(true).as_deref(), Some("Loading more refs...")); + assert_eq!(Page::default().status(true).as_deref(), Some("Loading refs...")); + + let whole = Page::decode(&json!({"refs": [], "nextCursor": null, "totalCount": 0})); + assert_eq!(whole.status(false), None); + } +} diff --git a/apps/desktop-gpui/src/usage.rs b/apps/desktop-gpui/src/usage.rs new file mode 100644 index 000000000000..a4bdccdd512d --- /dev/null +++ b/apps/desktop-gpui/src/usage.rs @@ -0,0 +1,326 @@ +//! The usage limits row of the thread details card: `ThreadDetailsUsage.tsx` over +//! `packages/shared/src/usageLimits.ts`. A provider that cannot report limits shows one muted +//! "Limits Unavailable" row whose tooltip says why. + +use serde_json::Value; + +const MINUTE: i64 = 60_000; +const HOUR: i64 = 60 * MINUTE; +const DAY: i64 = 24 * HOUR; + +/// `ServerProviderUsageWindow`. +#[derive(Clone, Debug, PartialEq)] +pub struct Window { + pub label: String, + pub used_percent: f64, + pub resets_at: Option, + pub duration_mins: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Reason { + Unsupported, + ProbeFailed, +} + +/// `ServerProviderUsageLimits`. +#[derive(Clone, Debug, PartialEq)] +pub struct Limits { + pub windows: Vec, + pub unavailable: Option<(Reason, Option)>, +} + +impl Limits { + /// `None` when the provider reports no `usageLimits` at all. + pub fn decode(value: &Value) -> Option { + if !value.is_object() { + return None; + } + let windows = value["windows"] + .as_array() + .into_iter() + .flatten() + .filter_map(|window| { + Some(Window { + label: window["label"].as_str()?.to_owned(), + used_percent: window["usedPercent"].as_f64()?, + resets_at: window["resetsAt"].as_str().and_then(parse_timestamp), + duration_mins: window["windowDurationMins"].as_f64(), + }) + }) + .collect(); + let unavailable = value["unavailable"].as_object().and_then(|entry| { + let reason = match entry.get("reason")?.as_str()? { + "unsupported" => Reason::Unsupported, + "probeFailed" => Reason::ProbeFailed, + _ => return None, + }; + let message = entry + .get("message") + .and_then(Value::as_str) + .map(str::to_owned); + Some((reason, message)) + }); + Some(Self { + windows, + unavailable, + }) + } + + /// `limitsNotice`: why the windows cannot be shown, or `None` when they can. + pub fn notice(&self) -> Option { + match &self.unavailable { + Some((Reason::Unsupported, message)) => Some( + message + .clone() + .unwrap_or_else(|| "This account has no subscription limits.".to_owned()), + ), + Some((Reason::ProbeFailed, message)) => Some( + message + .clone() + .unwrap_or_else(|| "Could not read limits.".to_owned()), + ), + None if self.windows.is_empty() => Some("No limits reported.".to_owned()), + None => None, + } + } +} + +impl Window { + /// `remainingPercent`. + pub fn remaining_percent(&self) -> i64 { + (100. - self.used_percent.clamp(0., 100.)).round() as i64 + } + + /// `paceOf`: `None` without a reset time and window length. + pub fn pace(&self, now: i64) -> Option { + let resets_at = self.resets_at?; + let length = (self.duration_mins? * MINUTE as f64) as i64; + if length <= 0 { + return None; + } + let elapsed = ((length - (resets_at - now)) as f64 / length as f64).clamp(0., 1.); + let gap = self.used_percent - elapsed * 100.; + Some(if gap > 5. { + Pace::Ahead + } else if gap < -5. { + Pace::Under + } else { + Pace::On + }) + } + + /// The row's tooltip lines: room left, when it resets, and the pace. + pub fn tooltip(&self, now: i64) -> Vec { + let mut lines = vec![format!( + "{}: {}% left", + self.label, + self.remaining_percent() + )]; + if let Some(resets_at) = self.resets_at.filter(|at| *at > now) { + lines.push(format!("Resets in {}", format_duration(resets_at - now))); + } + if let Some(pace) = self.pace(now) { + lines.push(pace.label().to_owned()); + } + lines + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Pace { + Ahead, + On, + Under, +} + +impl Pace { + pub fn label(self) -> &'static str { + match self { + Self::Ahead => "Ahead of pace", + Self::On => "On pace", + Self::Under => "Under pace", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tone { + Error, + Warning, +} + +/// The tone `LimitRow` gives a bar: red at 10% left, amber at 20%. +pub fn tone(left: i64) -> Option { + if left <= 10 { + Some(Tone::Error) + } else if left <= 20 { + Some(Tone::Warning) + } else { + None + } +} + +/// `formatDuration`. +pub fn format_duration(ms: i64) -> String { + let remaining = ms.max(0); + let days = remaining / DAY; + let hours = remaining % DAY / HOUR; + let minutes = remaining % HOUR / MINUTE; + if days > 0 { + format!("{days}d {hours}h") + } else if hours > 0 { + format!("{hours}h {minutes}m") + } else { + format!("{minutes}m") + } +} + +/// Milliseconds since the epoch of an ISO 8601 timestamp (`Date.parse` for the forms the server writes). +pub fn parse_timestamp(text: &str) -> Option { + let (date, rest) = text.split_once('T')?; + let mut parts = date.split('-'); + let year: i64 = parts.next()?.parse().ok()?; + let month: i64 = parts.next()?.parse().ok()?; + let day: i64 = parts.next()?.parse().ok()?; + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + let split = rest.find(['Z', '+', '-']).unwrap_or(rest.len()); + let (clock, zone) = rest.split_at(split); + let mut fields = clock.split(':'); + let hour: i64 = fields.next()?.parse().ok()?; + let minute: i64 = fields.next()?.parse().ok()?; + let seconds: f64 = fields.next().unwrap_or("0").parse().ok()?; + let offset = match zone { + "" | "Z" => 0, + zone => { + let sign = if zone.starts_with('-') { -1 } else { 1 }; + let (h, m) = zone[1..].split_once(':')?; + sign * (h.parse::().ok()? * HOUR + m.parse::().ok()? * MINUTE) + } + }; + // Howard Hinnant's days_from_civil. + let y = if month <= 2 { year - 1 } else { year }; + let era = y.div_euclid(400); + let yoe = y - era * 400; + let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146_097 + doe - 719_468; + Some(days * DAY + hour * HOUR + minute * MINUTE + (seconds * 1000.).round() as i64 - offset) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn window(used: f64, resets: Option, mins: Option) -> Window { + Window { + label: "5h".to_owned(), + used_percent: used, + resets_at: resets, + duration_mins: mins, + } + } + + #[test] + fn a_failed_probe_names_the_error() { + let limits = Limits::decode(&json!({ + "windows": [], + "unavailable": { + "reason": "probeFailed", + "message": "Codex could not read usage (JSON-RPC -32600)." + }, + "checkedAt": "2026-01-01T00:00:00.000Z", + })) + .expect("limits"); + assert_eq!( + limits.notice().as_deref(), + Some("Codex could not read usage (JSON-RPC -32600).") + ); + } + + #[test] + fn notices_fall_back_to_the_stock_wording() { + let notice = |value: Value| Limits::decode(&value).expect("limits").notice(); + let unsupported = json!({"windows": [], "unavailable": {"reason": "unsupported"}}); + assert_eq!( + notice(unsupported).as_deref(), + Some("This account has no subscription limits.") + ); + let failed = json!({"windows": [], "unavailable": {"reason": "probeFailed"}}); + assert_eq!(notice(failed).as_deref(), Some("Could not read limits.")); + assert_eq!( + notice(json!({"windows": []})).as_deref(), + Some("No limits reported.") + ); + let windows = json!({"windows": [{"id": "a", "kind": "session", "label": "5h", "usedPercent": 10}]}); + assert_eq!(notice(windows), None); + assert!(Limits::decode(&Value::Null).is_none()); + } + + #[test] + fn remaining_percent_clamps_and_rounds() { + assert_eq!(window(0., None, None).remaining_percent(), 100); + assert_eq!(window(33.4, None, None).remaining_percent(), 67); + assert_eq!(window(140., None, None).remaining_percent(), 0); + assert_eq!(window(-5., None, None).remaining_percent(), 100); + } + + #[test] + fn tones_flag_the_last_fifth() { + assert_eq!(tone(10), Some(Tone::Error)); + assert_eq!(tone(11), Some(Tone::Warning)); + assert_eq!(tone(20), Some(Tone::Warning)); + assert_eq!(tone(21), None); + } + + #[test] + fn durations_read_in_the_two_largest_units() { + assert_eq!(format_duration(-5), "0m"); + assert_eq!(format_duration(59 * MINUTE), "59m"); + assert_eq!(format_duration(HOUR + 5 * MINUTE), "1h 5m"); + assert_eq!(format_duration(2 * DAY + 3 * HOUR + 59 * MINUTE), "2d 3h"); + } + + #[test] + fn pace_compares_use_with_time_elapsed() { + let now = 1_000 * HOUR; + // A 10 hour window with 5 hours left is half elapsed. + let at = |used| window(used, Some(now + 5 * HOUR), Some(600.)).pace(now); + assert_eq!(at(60.), Some(Pace::Ahead)); + assert_eq!(at(55.), Some(Pace::On)); + assert_eq!(at(45.), Some(Pace::On)); + assert_eq!(at(44.), Some(Pace::Under)); + assert_eq!(window(50., None, Some(600.)).pace(now), None); + assert_eq!(window(50., Some(now), None).pace(now), None); + } + + #[test] + fn the_tooltip_lists_room_reset_and_pace() { + let now = 1_000 * HOUR; + let shown = window(50., Some(now + 5 * HOUR), Some(600.)); + assert_eq!( + shown.tooltip(now), + ["5h: 50% left", "Resets in 5h 0m", "On pace"] + ); + let past = window(50., Some(now - 1), None); + assert_eq!(past.tooltip(now), ["5h: 50% left"]); + } + + #[test] + fn timestamps_parse_like_date_parse() { + assert_eq!(parse_timestamp("1970-01-01T00:00:00Z"), Some(0)); + assert_eq!( + parse_timestamp("2026-01-01T00:00:00.000Z"), + Some(1_767_225_600_000) + ); + assert_eq!( + parse_timestamp("2026-01-01T02:00:00+02:00"), + Some(1_767_225_600_000) + ); + assert_eq!(parse_timestamp("2024-02-29T12:30:15.500Z"), Some(1_709_209_815_500)); + assert_eq!(parse_timestamp("soon"), None); + } +} From 90934ea703d4f7848a3dd71c2c6a0809109d1a83 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 13:04:09 -0500 Subject: [PATCH 31/82] feat(desktop-gpui): the connection carries the details card's ref, pull and git action calls --- apps/desktop-gpui/src/connection.rs | 159 ++++++++++++++++++++++++++- apps/desktop-gpui/src/details.rs | 4 +- apps/desktop-gpui/src/git_actions.rs | 10 +- apps/desktop-gpui/src/providers.rs | 13 +++ apps/desktop-gpui/src/refs.rs | 15 ++- apps/desktop-gpui/src/rpc.rs | 38 +++++++ apps/desktop-gpui/src/status.rs | 14 +++ apps/desktop-gpui/src/usage.rs | 8 +- 8 files changed, 246 insertions(+), 15 deletions(-) diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 501e88981145..aedd12c777f5 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -7,12 +7,13 @@ use crate::electron_dpop; use crate::projects::MachineKind; use crate::providers::Catalog; use crate::rpc::{ - Event, PING_FRAME, ProjectsMutate, ReviewGetDiffPreview, ServerGetConfig, Session, - ShellOpenInEditor, SubscribeShell, SubscribeThread, SubscribeVcsStatus, TerminalAttach, - TerminalClose, TerminalResize, TerminalWrite, VcsListRefs, + Event, GitRunStackedAction, PING_FRAME, ProjectsMutate, ReviewGetDiffPreview, ServerGetConfig, + Session, ShellOpenInEditor, SubscribeShell, SubscribeThread, SubscribeVcsStatus, + TerminalAttach, TerminalClose, TerminalResize, TerminalWrite, VcsCreateRef, VcsListRefs, + VcsPull, VcsRefreshStatus, VcsSwitchRef, }; use crate::shell::ShellState; -use crate::status::{Status, StatusSender, publish}; +use crate::status::{CallEvent, Status, StatusSender, publish}; use crate::strip::{self, Branch}; use crate::terminal::{self, Grid, Target}; use crate::thread::ThreadState; @@ -80,6 +81,24 @@ pub enum Action { TerminalClose { thread_id: String, }, + /// A request the details card makes; the outcome comes back as `Status::Call` with `token`. + Call { + token: u64, + call: Call, + payload: serde_json::Value, + }, +} + +/// The RPCs an `Action::Call` can name, all with untyped payloads. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Call { + ListRefs, + SwitchRef, + CreateRef, + Pull, + RefreshStatus, + /// A stream: progress events, then the result. + RunStackedAction, } pub type ActionSender = async_channel::Sender; @@ -99,6 +118,35 @@ impl Action { Self::TerminalWrite { .. } => "write to the terminal", Self::TerminalResize { .. } => "resize the terminal", Self::TerminalClose { .. } => "close the terminal", + Self::Call { call, .. } => call.describe(), + } + } +} + +impl Call { + fn describe(self) -> &'static str { + match self { + Self::ListRefs => "list the refs", + Self::SwitchRef => "switch the ref", + Self::CreateRef => "create the ref", + Self::Pull => "pull", + Self::RefreshStatus => "refresh the status", + Self::RunStackedAction => "run the action", + } + } + + fn request( + self, + session: &mut Session, + payload: &serde_json::Value, + ) -> Result<(String, String), String> { + match self { + Self::ListRefs => session.request::(payload), + Self::SwitchRef => session.request::(payload), + Self::CreateRef => session.request::(payload), + Self::Pull => session.request::(payload), + Self::RefreshStatus => session.request::(payload), + Self::RunStackedAction => session.request::(payload), } } } @@ -358,6 +406,8 @@ async fn serve( let mut branches: HashMap = HashMap::new(); // Diff loads by request id, with the directory each was for. let mut diffs: HashMap = HashMap::new(); + // The card's calls by request id, with the token its answers carry. + let mut calls: HashMap = HashMap::new(); let mut ping = interval(PING_INTERVAL); ping.set_missed_tick_behavior(MissedTickBehavior::Delay); let mut deadline = Instant::now() + PING_TIMEOUT; @@ -372,6 +422,21 @@ async fn serve( let Ok(action) = action else { return "window closed".to_owned(); }; + if let Action::Call { token, call, payload } = &action { + match call.request(&mut session, payload) { + Ok((id, frame)) => { + calls.insert(id, *token); + if let Err(error) = sink.send(Message::text(frame)).await { + return error.to_string(); + } + } + Err(error) => publish( + status, + Status::Call { token: *token, event: CallEvent::Failed(error) }, + ), + } + continue; + } let request = match &action { Action::SelectThread(id) => { *selected = id.clone(); @@ -434,6 +499,7 @@ async fn serve( Action::LoadBranch { root } => session .request::(&refs_query(root)) .map(|(id, frame)| (id, frame, Some(root.clone()))), + Action::Call { .. } => continue, }; match request { Ok((id, frame, root)) => { @@ -488,6 +554,9 @@ async fn serve( publish_diff(cwd, event, status); continue; } + let Some(event) = route_call(&mut calls, event, status) else { + continue; + }; if let Some(root) = request_id(&event).and_then(|id| branches.remove(id)) { publish_branch(root, event, status); continue; @@ -796,6 +865,50 @@ fn owns(event: &Event, id: &str) -> bool { request_id(event) == Some(id) } +/// Publishes the outcome of a card call; hands back what belongs to no call. +fn route_call( + calls: &mut HashMap, + event: Event, + status: &StatusSender, +) -> Option { + let (id, answer) = match event { + Event::Item { request_id, value } => (request_id, CallEvent::Progress(value)), + Event::Done { request_id, value } => (request_id, CallEvent::Done(value)), + Event::Failed { + request_id, + message, + } => (request_id, CallEvent::Failed(message)), + other => return Some(other), + }; + let Some(token) = calls.get(&id).copied() else { + return Some(match answer { + CallEvent::Progress(value) => Event::Item { + request_id: id, + value, + }, + CallEvent::Done(value) => Event::Done { + request_id: id, + value, + }, + CallEvent::Failed(message) => Event::Failed { + request_id: id, + message, + }, + }); + }; + if !matches!(answer, CallEvent::Progress(_)) { + calls.remove(&id); + } + publish( + status, + Status::Call { + token, + event: answer, + }, + ); + None +} + /// Publishes the branch of a workspace root; a failure leaves the strip without git controls. fn publish_branch(root: String, event: Event, status: &StatusSender) { let branch = match event { @@ -931,6 +1044,44 @@ mod tests { assert!(!settle(&mut pending, &failed("7"))); } + #[test] + fn call_events_carry_their_token_and_others_pass_through() { + let (status, published) = async_channel::unbounded(); + let mut calls = HashMap::from([("4".to_owned(), 9u64)]); + let item = Event::Item { + request_id: "4".to_owned(), + value: serde_json::json!({"type": "phase_started"}), + }; + assert!(route_call(&mut calls, item, &status).is_none()); + assert!(calls.contains_key("4")); + let done = Event::Done { + request_id: "4".to_owned(), + value: serde_json::Value::Null, + }; + assert!(route_call(&mut calls, done, &status).is_none()); + assert!(calls.is_empty()); + let other = Event::Failed { + request_id: "5".to_owned(), + message: "no".to_owned(), + }; + assert!(route_call(&mut calls, other, &status).is_some()); + assert!(matches!( + published.try_recv(), + Ok(Status::Call { + token: 9, + event: CallEvent::Progress(_) + }) + )); + assert!(matches!( + published.try_recv(), + Ok(Status::Call { + token: 9, + event: CallEvent::Done(_) + }) + )); + assert!(published.try_recv().is_err()); + } + #[test] fn thread_events_are_routed_and_interrupted_ones_swallowed() { let (status, published) = async_channel::unbounded(); diff --git a/apps/desktop-gpui/src/details.rs b/apps/desktop-gpui/src/details.rs index 4fb01107f149..4f1a9b0f20d8 100644 --- a/apps/desktop-gpui/src/details.rs +++ b/apps/desktop-gpui/src/details.rs @@ -271,7 +271,9 @@ impl Local { }) }) .collect(), - base_ref: value["branchChanges"]["baseRef"].as_str().map(str::to_owned), + base_ref: value["branchChanges"]["baseRef"] + .as_str() + .map(str::to_owned), provider_kind: value["sourceControlProvider"]["kind"] .as_str() .map(str::to_owned), diff --git a/apps/desktop-gpui/src/git_actions.rs b/apps/desktop-gpui/src/git_actions.rs index 82d4f0d48d34..bd685089d7ac 100644 --- a/apps/desktop-gpui/src/git_actions.rs +++ b/apps/desktop-gpui/src/git_actions.rs @@ -343,7 +343,10 @@ mod tests { } fn pr_item(items: &[MenuItem]) -> &MenuItem { - items.iter().find(|item| item.id == MenuId::Pr).expect("pr item") + items + .iter() + .find(|item| item.id == MenuId::Pr) + .expect("pr item") } #[test] @@ -459,7 +462,10 @@ mod tests { json!({}), ); assert_eq!(quick(&dirty).label, "Commit, push & MR"); - assert_eq!(pr_item(&build_menu_items(Some(&ahead), false, true)).label, "Create MR"); + assert_eq!( + pr_item(&build_menu_items(Some(&ahead), false, true)).label, + "Create MR" + ); let copy = default_branch_dialog_copy( Action::CreatePr, "main", diff --git a/apps/desktop-gpui/src/providers.rs b/apps/desktop-gpui/src/providers.rs index 30bdcba3c337..ae75a4049a52 100644 --- a/apps/desktop-gpui/src/providers.rs +++ b/apps/desktop-gpui/src/providers.rs @@ -4,6 +4,7 @@ //! which the generated wire types do not cover. use crate::strip::Defaults; +use crate::usage::Limits; use serde::de::DeserializeOwned; use serde::{Deserialize, Deserializer}; use serde_json::Value; @@ -150,6 +151,9 @@ struct Provider { availability: Option, #[serde(default, deserialize_with = "lenient")] models: Vec, + /// Kept raw: `usage::Limits` decodes it for the details card. + #[serde(default)] + usage_limits: Option, } #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] @@ -325,6 +329,14 @@ impl Catalog { ) } + /// The usage limits an instance reports, for the details card's limits row. + pub fn usage_limits(&self, instance_id: &str) -> Option { + self.providers + .iter() + .find(|provider| provider.instance_id == instance_id) + .and_then(|provider| Limits::decode(provider.usage_limits.as_ref()?)) + } + /// A provider instance's icon, for the sidebar's thread cards. pub fn instance(&self, instance_id: &str) -> Option { let entries = self.entries(None); @@ -1000,6 +1012,7 @@ mod tests { status: "ready".into(), availability: None, models: Vec::new(), + usage_limits: None, }; assert_eq!(display_name(&named("codex", Some("Codex"))), "Codex"); assert_eq!( diff --git a/apps/desktop-gpui/src/refs.rs b/apps/desktop-gpui/src/refs.rs index f7ae278be9a4..d6f58bf94a79 100644 --- a/apps/desktop-gpui/src/refs.rs +++ b/apps/desktop-gpui/src/refs.rs @@ -198,7 +198,10 @@ mod tests { fn automatic_tails_start_on_a_word() { assert_eq!(tail_of("t3/clone-repo-to-other-machine"), "machine"); assert_eq!(tail_of("t3/investigate-release-build-times"), "times"); - assert_eq!(tail_of("t3/compare-long-featurenames-ab"), "featurenames-ab"); + assert_eq!( + tail_of("t3/compare-long-featurenames-ab"), + "featurenames-ab" + ); assert_eq!(tail_of("t3/abcdefghijklmnopqrstuvwxyz"), "qrstuvwxyz"); } @@ -248,10 +251,7 @@ mod tests { }), Some(Tag::Worktree) ); - assert_eq!( - with(|r| r.worktree_path = Some("/repo".to_owned())), - None - ); + assert_eq!(with(|r| r.worktree_path = Some("/repo".to_owned())), None); assert_eq!( with(|r| { r.current = true; @@ -293,7 +293,10 @@ mod tests { assert_eq!(page.total, 768); assert_eq!(page.status(false).as_deref(), Some("Showing 2 of 768 refs")); assert_eq!(page.status(true).as_deref(), Some("Loading more refs...")); - assert_eq!(Page::default().status(true).as_deref(), Some("Loading refs...")); + assert_eq!( + Page::default().status(true).as_deref(), + Some("Loading refs...") + ); let whole = Page::decode(&json!({"refs": [], "nextCursor": null, "totalCount": 0})); assert_eq!(whole.status(false), None); diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs index 2baf2119c335..5f104f29c937 100644 --- a/apps/desktop-gpui/src/rpc.rs +++ b/apps/desktop-gpui/src/rpc.rs @@ -126,6 +126,44 @@ impl Method for TerminalClose { type Payload = Value; type Success = Value; } +macro_rules! untyped_method { + ($(#[$doc:meta])* $name:ident, $tag:literal) => { + $(#[$doc])* + pub struct $name; + + impl Method for $name { + const TAG: &'static str = $tag; + type Payload = Value; + type Success = Value; + } + }; +} + +untyped_method!( + /// `vcs.switchRef`: checks out a ref in `cwd`. + VcsSwitchRef, + "vcs.switchRef" +); +untyped_method!( + /// `vcs.createRef`: creates a branch and, with `switchRef`, checks it out. + VcsCreateRef, + "vcs.createRef" +); +untyped_method!( + /// `vcs.pull`: pulls the current branch from its upstream. + VcsPull, + "vcs.pull" +); +untyped_method!( + /// `vcs.refreshStatus`: re-reads the status the card watches. + VcsRefreshStatus, + "vcs.refreshStatus" +); +untyped_method!( + /// `git.runStackedAction`, a stream of progress events ending with the action's result. + GitRunStackedAction, + "git.runStackedAction" +); /// Request ids are bigints on the wire, so they travel as strings. fn id_text<'de, D: Deserializer<'de>>(deserializer: D) -> Result { diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index 4a08daf12a38..9f77c5b22678 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -54,6 +54,19 @@ pub enum Status { cwd: String, diff: Result, }, + /// The outcome of a request the details card made, by the token it was sent with. + Call { + token: u64, + event: CallEvent, + }, +} + +/// One answer to a card call: streams report `Progress` before they finish. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum CallEvent { + Progress(serde_json::Value), + Done(serde_json::Value), + Failed(String), } /// Where the web sidebar lists a thread. @@ -203,6 +216,7 @@ impl fmt::Display for Status { Self::Vcs { cwd, .. } => write!(f, "Status of {cwd}"), Self::Terminal { thread_id, .. } => write!(f, "Terminal of {thread_id}"), Self::Diff { cwd, .. } => write!(f, "Diff of {cwd}"), + Self::Call { token, .. } => write!(f, "Call {token}"), } } } diff --git a/apps/desktop-gpui/src/usage.rs b/apps/desktop-gpui/src/usage.rs index a4bdccdd512d..f8a48b39eacf 100644 --- a/apps/desktop-gpui/src/usage.rs +++ b/apps/desktop-gpui/src/usage.rs @@ -255,7 +255,8 @@ mod tests { notice(json!({"windows": []})).as_deref(), Some("No limits reported.") ); - let windows = json!({"windows": [{"id": "a", "kind": "session", "label": "5h", "usedPercent": 10}]}); + let windows = + json!({"windows": [{"id": "a", "kind": "session", "label": "5h", "usedPercent": 10}]}); assert_eq!(notice(windows), None); assert!(Limits::decode(&Value::Null).is_none()); } @@ -320,7 +321,10 @@ mod tests { parse_timestamp("2026-01-01T02:00:00+02:00"), Some(1_767_225_600_000) ); - assert_eq!(parse_timestamp("2024-02-29T12:30:15.500Z"), Some(1_709_209_815_500)); + assert_eq!( + parse_timestamp("2024-02-29T12:30:15.500Z"), + Some(1_709_209_815_500) + ); assert_eq!(parse_timestamp("soon"), None); } } From ba4dc200d41b7fe1a9b66115ffe3b273b3021c7b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 13:06:28 -0500 Subject: [PATCH 32/82] feat(desktop-gpui): embed the editor icons and the icons the details card needs --- apps/desktop-gpui/src/assets.rs | 65 ++++++++++++++++++- apps/desktop-gpui/src/editor-icons/finder.svg | 1 + apps/desktop-gpui/src/editor-icons/idea.svg | 1 + apps/desktop-gpui/src/editor-icons/vscode.svg | 1 + .../src/editor-icons/zed-dark.svg | 1 + .../src/editor-icons/zed-light.svg | 1 + 6 files changed, 69 insertions(+), 1 deletion(-) create mode 100644 apps/desktop-gpui/src/editor-icons/finder.svg create mode 100644 apps/desktop-gpui/src/editor-icons/idea.svg create mode 100644 apps/desktop-gpui/src/editor-icons/vscode.svg create mode 100644 apps/desktop-gpui/src/editor-icons/zed-dark.svg create mode 100644 apps/desktop-gpui/src/editor-icons/zed-light.svg diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 574c92ffc8f0..495000327114 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -45,7 +45,10 @@ gpui_kit_assets::icon_assets!( PanelBottom, ChevronDown, SquareArrowOutUpRight, - FileDiff + FileDiff, + CloudUpload, + GitBranchPlus, + GitCommitHorizontal ] ); @@ -149,6 +152,46 @@ pub fn provider_icon(driver: &str, dark: bool) -> Option<&'static str> { .map(|(path, _)| *path) } +/// The editor brand marks the web's `OpenInPicker` draws that the provider icons do not already cover. +const EDITOR_ICONS: &[(&str, &[u8])] = &[ + ( + "editors/finder.svg", + include_bytes!("editor-icons/finder.svg") as &[u8], + ), + ( + "editors/idea.svg", + include_bytes!("editor-icons/idea.svg") as &[u8], + ), + ( + "editors/vscode.svg", + include_bytes!("editor-icons/vscode.svg") as &[u8], + ), + ( + "editors/zed-dark.svg", + include_bytes!("editor-icons/zed-dark.svg") as &[u8], + ), + ( + "editors/zed-light.svg", + include_bytes!("editor-icons/zed-light.svg") as &[u8], + ), +]; + +/// The brand icon for an editor id, or `None` where the web falls back to a generic glyph. +pub fn editor_icon(editor: &str, dark: bool) -> Option<&'static str> { + let scheme = if dark { "dark" } else { "light" }; + match editor { + "cursor" | "kiro" | "antigravity" => provider_icon(editor, dark), + "vscode" => Some("editors/vscode.svg"), + "idea" => Some("editors/idea.svg"), + "file-manager" => Some("editors/finder.svg"), + "zed" => EDITOR_ICONS + .iter() + .map(|(path, _)| *path) + .find(|path| *path == format!("editors/zed-{scheme}.svg")), + _ => None, + } +} + pub struct AppAssets; impl AssetSource for AppAssets { @@ -165,6 +208,9 @@ impl AssetSource for AppAssets { if let Some((_, bytes)) = PROVIDER_ICONS.iter().find(|(known, _)| *known == path) { return Ok(Some(Cow::Borrowed(bytes))); } + if let Some((_, bytes)) = EDITOR_ICONS.iter().find(|(known, _)| *known == path) { + return Ok(Some(Cow::Borrowed(bytes))); + } match ExtraIcons.load(path)? { Some(bytes) => Ok(Some(bytes)), None => gpui_kit_assets::Assets.load(path), @@ -230,6 +276,9 @@ mod tests { IconName::Layers, IconName::ArrowRightLeft, IconName::Computer, + IconName::CloudUpload, + IconName::GitBranchPlus, + IconName::GitCommitHorizontal, ] { let loaded = AppAssets.load(&icon.path()).ok().flatten(); assert!(loaded.is_some(), "{} is not embedded", icon.path()); @@ -243,6 +292,20 @@ mod tests { ); } + #[test] + fn editor_icons_resolve_and_load() { + assert_eq!(editor_icon("zed", true), Some("editors/zed-dark.svg")); + assert_eq!(editor_icon("zed", false), Some("editors/zed-light.svg")); + assert_eq!(editor_icon("cursor", true), Some("providers/cursor-dark.svg")); + assert_eq!(editor_icon("trae", true), None); + for id in ["cursor", "kiro", "vscode", "zed", "antigravity", "idea", "file-manager"] { + for dark in [true, false] { + let path = editor_icon(id, dark).expect(id); + assert!(AppAssets.load(path).ok().flatten().is_some(), "{path}"); + } + } + } + #[test] fn provider_icons_follow_the_color_scheme() { assert_eq!( diff --git a/apps/desktop-gpui/src/editor-icons/finder.svg b/apps/desktop-gpui/src/editor-icons/finder.svg new file mode 100644 index 000000000000..eace3add9a8e --- /dev/null +++ b/apps/desktop-gpui/src/editor-icons/finder.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/editor-icons/idea.svg b/apps/desktop-gpui/src/editor-icons/idea.svg new file mode 100644 index 000000000000..23864bed3d61 --- /dev/null +++ b/apps/desktop-gpui/src/editor-icons/idea.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/editor-icons/vscode.svg b/apps/desktop-gpui/src/editor-icons/vscode.svg new file mode 100644 index 000000000000..d2edabf84065 --- /dev/null +++ b/apps/desktop-gpui/src/editor-icons/vscode.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/editor-icons/zed-dark.svg b/apps/desktop-gpui/src/editor-icons/zed-dark.svg new file mode 100644 index 000000000000..d93d3fe25821 --- /dev/null +++ b/apps/desktop-gpui/src/editor-icons/zed-dark.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/editor-icons/zed-light.svg b/apps/desktop-gpui/src/editor-icons/zed-light.svg new file mode 100644 index 000000000000..8b108c99fd3e --- /dev/null +++ b/apps/desktop-gpui/src/editor-icons/zed-light.svg @@ -0,0 +1 @@ + From e60b8a04a912c71b633ffd236181ce5606d00980 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 13:19:56 -0500 Subject: [PATCH 33/82] feat(desktop-gpui): usage rows in the card layout, ref paging and git action progress logic --- apps/desktop-gpui/src/details.rs | 57 ++++++++--- apps/desktop-gpui/src/git_actions.rs | 142 +++++++++++++++++++++++++++ apps/desktop-gpui/src/refs.rs | 30 +----- 3 files changed, 190 insertions(+), 39 deletions(-) diff --git a/apps/desktop-gpui/src/details.rs b/apps/desktop-gpui/src/details.rs index 4f1a9b0f20d8..88c686e7c209 100644 --- a/apps/desktop-gpui/src/details.rs +++ b/apps/desktop-gpui/src/details.rs @@ -20,6 +20,8 @@ const MIN_CARD_HEIGHT: f32 = 160.; /// A `h-8` row, and a section's `pt-2 pb-2.5` with its 1px divider when separated. const ROW_HEIGHT: f32 = 32.; const SECTION_PADDING: f32 = 18.; +/// A usage row is `h-6`. +const USAGE_ROW_HEIGHT: f32 = 24.; /// What room there is for the card in the main area, or `None` when it does not fit beside the /// chat lane (the web falls back to a popover there). @@ -60,15 +62,19 @@ pub struct Inputs { pub has_editors: bool, /// A fresh draft or a thread with no run, with nothing to commit or push. pub quiet_empty_draft: bool, + /// The rows the usage limits would take: one per window, or one for the notice; 0 for none. + pub usage_rows: usize, } -/// The rows each section renders, as `ThreadDetailsPanel` flags them. Environment, scripts and -/// git actions are not in this client yet. +/// The rows each section renders, as `ThreadDetailsPanel` flags them. Environment and scripts +/// are not in this client yet. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Show { pub open_in: bool, pub branch: bool, + pub git_actions: bool, pub changes: bool, + pub usage: bool, } impl Show { @@ -76,20 +82,30 @@ impl Show { Self { open_in: inputs.has_cwd && inputs.has_editors && density != Density::Essential, branch: inputs.has_cwd && inputs.is_repo, + git_actions: inputs.has_cwd && inputs.is_repo, changes: inputs.has_cwd && inputs.is_repo && !inputs.quiet_empty_draft, + usage: inputs.usage_rows > 0 && density != Density::Essential, } } /// Nothing to render, so the card is left out rather than drawn empty. pub fn is_empty(self) -> bool { - !(self.open_in || self.branch || self.changes) + !(self.open_in || self.branch || self.git_actions || self.changes || self.usage) } /// The card's content height at `density`, standing in for the web's measured heights. - pub fn content_height(self, density: Density) -> f32 { + pub fn content_height(self, density: Density, usage_rows: usize) -> f32 { let workspace = self.open_in as u8 as f32 * ROW_HEIGHT; - let version = (self.branch as u8 + self.changes as u8) as f32 * ROW_HEIGHT; - let sections = [workspace, version].into_iter().filter(|rows| *rows > 0.); + let version = + (self.branch as u8 + self.git_actions as u8 + self.changes as u8) as f32 * ROW_HEIGHT; + let usage = if self.usage { + usage_rows as f32 * USAGE_ROW_HEIGHT + } else { + 0. + }; + let sections = [workspace, version, usage] + .into_iter() + .filter(|rows| *rows > 0.); let count = sections.clone().count() as f32; let divider = if density == Density::Full { (count - 1.).max(0.) @@ -102,7 +118,8 @@ impl Show { /// The density for `height`, from the content each density would render. pub fn density_for(height: f32, inputs: Inputs) -> Density { - let heights = |density| Show::resolve(density, inputs).content_height(density); + let heights = + |density| Show::resolve(density, inputs).content_height(density, inputs.usage_rows); resolve_density(height, heights(Density::Full), heights(Density::Compact)) } @@ -399,6 +416,7 @@ mod tests { is_repo: true, has_editors: true, quiet_empty_draft: false, + usage_rows: 0, } } @@ -433,10 +451,17 @@ mod tests { assert_eq!(resolve_density(350., 400., 300.), Density::Compact); assert_eq!(resolve_density(200., 400., 300.), Density::Essential); assert_eq!(resolve_density(10., 0., 0.), Density::Full); - // Three rows in two sections: 3 * 32 + 2 * 18 + a divider when full. - assert_eq!(density_for(133., inputs()), Density::Full); - assert_eq!(density_for(132., inputs()), Density::Compact); + // Four rows in two sections: 4 * 32 + 2 * 18 + a divider when full. + assert_eq!(density_for(165., inputs()), Density::Full); + assert_eq!(density_for(164., inputs()), Density::Compact); assert_eq!(density_for(100., inputs()), Density::Essential); + // Two usage windows add a third section of two 24px rows. + let limited = Inputs { + usage_rows: 2, + ..inputs() + }; + assert_eq!(density_for(165. + 48. + 18. + 1., limited), Density::Full); + assert_eq!(density_for(165. + 48. + 18., limited), Density::Compact); } #[test] @@ -451,15 +476,21 @@ mod tests { Show { open_in: true, branch: true, - changes: true + git_actions: true, + changes: true, + usage: false } ); + assert!(show(Density::Full, |i| i.usage_rows = 1).usage); + assert!(!show(Density::Essential, |i| i.usage_rows = 1).usage); assert!(!show(Density::Essential, |_| {}).open_in); assert!(!show(Density::Full, |i| i.has_editors = false).open_in); let not_repo = show(Density::Full, |i| i.is_repo = false); - assert!(not_repo.open_in && !not_repo.branch && !not_repo.changes); + assert!( + not_repo.open_in && !not_repo.branch && !not_repo.git_actions && !not_repo.changes + ); let quiet = show(Density::Full, |i| i.quiet_empty_draft = true); - assert!(quiet.branch && !quiet.changes); + assert!(quiet.branch && quiet.git_actions && !quiet.changes); assert!(show(Density::Full, |i| i.has_cwd = false).is_empty()); } diff --git a/apps/desktop-gpui/src/git_actions.rs b/apps/desktop-gpui/src/git_actions.rs index bd685089d7ac..d51c427843d0 100644 --- a/apps/desktop-gpui/src/git_actions.rs +++ b/apps/desktop-gpui/src/git_actions.rs @@ -307,11 +307,153 @@ pub fn feature_branch_unavailable_reason(branch: &str, has_changes: bool) -> Opt }) } +/// What the primary button reports while a stacked action runs (`GitActionsControl.tsx`'s +/// progress state): the phase, and the last line of hook output. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Progress { + pub phase: Option, + pub hook: Option, + pub output: Option, +} + +/// How a stacked action ended. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Outcome { + Success { + title: String, + description: Option, + }, + Failed(String), +} + +impl Progress { + pub const STARTING: &'static str = "Starting source control action..."; + + /// The status line: a running hook, else the phase. + pub fn label(&self) -> String { + match (&self.hook, &self.phase) { + (Some(hook), _) => format!("Running {hook}..."), + (None, Some(phase)) => phase.clone(), + (None, None) => Self::STARTING.to_owned(), + } + } + + /// Folds one `GitActionProgressEvent`; the finishing events give the outcome. + pub fn apply(&mut self, event: &serde_json::Value) -> Option { + match event["kind"].as_str()? { + "phase_started" => { + self.phase = event["label"].as_str().map(str::to_owned); + self.hook = None; + } + "hook_started" => self.hook = event["hookName"].as_str().map(str::to_owned), + "hook_output" => { + let line = event["text"] + .as_str() + .and_then(|text| text.lines().rev().find(|line| !line.trim().is_empty())); + if let Some(line) = line { + self.output = Some(line.trim().to_owned()); + } + } + "hook_finished" => self.hook = None, + "action_finished" => return Some(Self::finished(&event["result"])), + "action_failed" => { + let message = event["message"].as_str(); + return Some(Outcome::Failed( + message.unwrap_or("Action failed.").to_owned(), + )); + } + _ => {} + } + None + } + + /// The toast of a result, or the plain wording when the server sent none. + pub fn finished(result: &serde_json::Value) -> Outcome { + let toast = &result["toast"]; + Outcome::Success { + title: toast["title"].as_str().unwrap_or("Done").to_owned(), + description: toast["description"].as_str().map(str::to_owned), + } + } +} + +/// `formatElapsedDescription`: seconds, then minutes and seconds. +pub fn format_elapsed(seconds: u64) -> String { + if seconds < 60 { + format!("{seconds}s") + } else { + format!("{}m {}s", seconds / 60, seconds % 60) + } +} + +/// The toast a pull result gives (`vcs.pull`): updated, or already in sync. +pub fn pull_outcome(result: &serde_json::Value) -> Outcome { + let name = result["refName"].as_str().unwrap_or("The branch"); + if result["status"] == "pulled" { + let from = result["upstreamRef"].as_str().unwrap_or("upstream"); + Outcome::Success { + title: "Pulled".to_owned(), + description: Some(format!("Updated {name} from {from}")), + } + } else { + Outcome::Success { + title: "Already up to date".to_owned(), + description: Some(format!("{name} is already synchronized.")), + } + } +} + #[cfg(test)] mod tests { use super::*; use serde_json::{Value, json}; + #[test] + fn progress_follows_the_stream() { + let mut progress = Progress::default(); + assert_eq!(progress.label(), Progress::STARTING); + assert!(progress.apply(&json!({"kind": "phase_started", "label": "Pushing..."})).is_none()); + assert_eq!(progress.label(), "Pushing..."); + progress.apply(&json!({"kind": "hook_started", "hookName": "pre-push"})); + assert_eq!(progress.label(), "Running pre-push..."); + progress.apply(&json!({"kind": "hook_output", "text": "lint\nok\n\n"})); + assert_eq!(progress.output.as_deref(), Some("ok")); + progress.apply(&json!({"kind": "hook_finished"})); + assert_eq!(progress.label(), "Pushing..."); + let done = progress.apply(&json!({ + "kind": "action_finished", + "result": {"toast": {"title": "Pushed", "description": "feature"}} + })); + assert_eq!( + done, + Some(Outcome::Success { + title: "Pushed".to_owned(), + description: Some("feature".to_owned()) + }) + ); + assert_eq!( + progress.apply(&json!({"kind": "action_failed", "message": "no remote"})), + Some(Outcome::Failed("no remote".to_owned())) + ); + assert!(progress.apply(&json!({"kind": "action_started"})).is_none()); + } + + #[test] + fn elapsed_and_pull_wording() { + assert_eq!(format_elapsed(9), "9s"); + assert_eq!(format_elapsed(75), "1m 15s"); + let pulled = pull_outcome(&json!({"status": "pulled", "refName": "main", "upstreamRef": "origin/main"})); + assert_eq!( + pulled, + Outcome::Success { + title: "Pulled".to_owned(), + description: Some("Updated main from origin/main".to_owned()) + } + ); + let synced = pull_outcome(&json!({"status": "skipped_up_to_date", "refName": "main"})); + assert!(matches!(synced, Outcome::Success { title, .. } if title == "Already up to date")); + } + fn status(local: Value, remote: Value) -> VcsStatus { let mut base = json!({ "isRepo": true, "hasPrimaryRemote": true, "isDefaultRef": false, diff --git a/apps/desktop-gpui/src/refs.rs b/apps/desktop-gpui/src/refs.rs index d6f58bf94a79..52c8e1694778 100644 --- a/apps/desktop-gpui/src/refs.rs +++ b/apps/desktop-gpui/src/refs.rs @@ -40,6 +40,8 @@ pub struct Page { pub refs: Vec, pub total: usize, pub has_more: bool, + /// Where the next page starts, from `nextCursor`. + pub next_cursor: Option, } impl Page { @@ -63,6 +65,7 @@ impl Page { .map_or(refs.len(), |count| count as usize); Self { has_more: !value["nextCursor"].is_null(), + next_cursor: value["nextCursor"].as_u64(), refs, total, } @@ -106,21 +109,6 @@ pub fn sanitize_new_ref_name(name: &str) -> String { name.split_whitespace().collect::>().join("-") } -/// `deriveLocalBranchNameFromRemoteRef`: `origin/feature/x` is the local `feature/x`. -pub fn local_name_of_remote(name: &str) -> &str { - name.split_once('/').map_or(name, |(_, rest)| rest) -} - -/// `shouldIncludeBranchPickerItem` for a ref: the query matches as typed or as a ref name. -pub fn matches_query(name: &str, query: &str) -> bool { - let query = query.trim().to_lowercase(); - if query.is_empty() { - return true; - } - let name = name.to_lowercase(); - name.contains(&query) || name.contains(&sanitize_new_ref_name(&query)) -} - const DEFAULT_TAIL: usize = 10; const MAX_SEGMENT_TAIL: usize = 16; const MIN_WORD_TAIL: usize = 4; @@ -262,17 +250,7 @@ mod tests { } #[test] - fn queries_match_as_typed_or_sanitized() { - assert!(matches_query("feature/Ui-Polish", "ui-pol")); - assert!(matches_query("feature/ui-polish", "ui polish")); - assert!(matches_query("anything", " ")); - assert!(!matches_query("main", "dev")); - } - - #[test] - fn names_derive_from_remote_refs() { - assert_eq!(local_name_of_remote("origin/feature/x"), "feature/x"); - assert_eq!(local_name_of_remote("main"), "main"); + fn new_names_are_sanitized() { assert_eq!(sanitize_new_ref_name(" my new branch "), "my-new-branch"); } From d22df87c8443bdb7765d92bdbfa767b33c07c78f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 13:19:57 -0500 Subject: [PATCH 34/82] feat(desktop-gpui): thread details card with editor menu, ref picker, git actions, changes and usage limits --- apps/desktop-gpui/src/assets.rs | 21 +- apps/desktop-gpui/src/details.rs | 4 +- apps/desktop-gpui/src/details_card.rs | 1662 +++++++++++++++++++++++++ apps/desktop-gpui/src/git_actions.rs | 10 +- apps/desktop-gpui/src/main.rs | 1 + apps/desktop-gpui/src/shell.rs | 2 +- apps/desktop-gpui/src/ui.rs | 347 +----- 7 files changed, 1734 insertions(+), 313 deletions(-) create mode 100644 apps/desktop-gpui/src/details_card.rs diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 495000327114..19df42b9239e 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -48,7 +48,9 @@ gpui_kit_assets::icon_assets!( FileDiff, CloudUpload, GitBranchPlus, - GitCommitHorizontal + GitCommitHorizontal, + CloudDownload, + Info ] ); @@ -279,6 +281,8 @@ mod tests { IconName::CloudUpload, IconName::GitBranchPlus, IconName::GitCommitHorizontal, + IconName::CloudDownload, + IconName::Info, ] { let loaded = AppAssets.load(&icon.path()).ok().flatten(); assert!(loaded.is_some(), "{} is not embedded", icon.path()); @@ -296,9 +300,20 @@ mod tests { fn editor_icons_resolve_and_load() { assert_eq!(editor_icon("zed", true), Some("editors/zed-dark.svg")); assert_eq!(editor_icon("zed", false), Some("editors/zed-light.svg")); - assert_eq!(editor_icon("cursor", true), Some("providers/cursor-dark.svg")); + assert_eq!( + editor_icon("cursor", true), + Some("providers/cursor-dark.svg") + ); assert_eq!(editor_icon("trae", true), None); - for id in ["cursor", "kiro", "vscode", "zed", "antigravity", "idea", "file-manager"] { + for id in [ + "cursor", + "kiro", + "vscode", + "zed", + "antigravity", + "idea", + "file-manager", + ] { for dark in [true, false] { let path = editor_icon(id, dark).expect(id); assert!(AppAssets.load(path).ok().flatten().is_some(), "{path}"); diff --git a/apps/desktop-gpui/src/details.rs b/apps/desktop-gpui/src/details.rs index 88c686e7c209..88e7d1f50320 100644 --- a/apps/desktop-gpui/src/details.rs +++ b/apps/desktop-gpui/src/details.rs @@ -486,9 +486,7 @@ mod tests { assert!(!show(Density::Essential, |_| {}).open_in); assert!(!show(Density::Full, |i| i.has_editors = false).open_in); let not_repo = show(Density::Full, |i| i.is_repo = false); - assert!( - not_repo.open_in && !not_repo.branch && !not_repo.git_actions && !not_repo.changes - ); + assert!(not_repo.open_in && !not_repo.branch && !not_repo.git_actions && !not_repo.changes); let quiet = show(Density::Full, |i| i.quiet_empty_draft = true); assert!(quiet.branch && quiet.git_actions && !quiet.changes); assert!(show(Density::Full, |i| i.has_cwd = false).is_empty()); diff --git a/apps/desktop-gpui/src/details_card.rs b/apps/desktop-gpui/src/details_card.rs new file mode 100644 index 000000000000..142f82c94eeb --- /dev/null +++ b/apps/desktop-gpui/src/details_card.rs @@ -0,0 +1,1662 @@ +//! The thread details card (`ThreadDetailsCard.tsx`): the editor split button +//! (`OpenInPicker.tsx`), the branch picker (`BranchToolbarBranchSelector.tsx`), the git action +//! split button (`GitActionsControl.tsx`), the changes row and the usage limits +//! (`ThreadDetailsUsage.tsx`). The entity owns the open popovers, the calls in flight and the +//! progress of a running action; `MainView` feeds it the thread's state through `sync`. + +use crate::assets::editor_icon; +use crate::connection::{Action, ActionSender, Call}; +use crate::details::{self, ChangedFile, Density, Inputs, Show, VcsStatus}; +use crate::git_actions::{ + self, Action as GitAction, MenuId, Outcome, Progress, QuickKind, Terminology, +}; +use crate::refs::{self, Page, Tag}; +use crate::status::CallEvent; +use crate::usage::{self, Limits, Tone}; +use gpui::{ + Anchor, AnyElement, App, Context, Entity, EventEmitter, Focusable as _, FontWeight, Hsla, + InteractiveElement as _, IntoElement, ParentElement, Render, RenderOnce, ScrollHandle, + StatefulInteractiveElement as _, Styled, WeakEntity, Window, div, img, prelude::*, px, + relative, +}; +use gpui_component::{ + ActiveTheme, Disableable, Icon, IconName, Selectable, Sizable, WindowExt as _, + button::{Button, ButtonVariants}, + checkbox::Checkbox, + h_flex, + input::{Input, InputEvent, InputState, Textarea, TextareaState}, + popover::Popover, + tooltip::Tooltip, + v_flex, +}; +use gpui_kit_assets::IconName as Icons; +use serde_json::{Value, json}; +use std::{ + collections::{HashMap, HashSet}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +/// `GIT_ACTION_SUCCESS_VISIBLE_MS`. +const SUCCESS_VISIBLE: Duration = Duration::from_secs(10); +/// The ref picker's page size (`REF_PAGE_SIZE`). +const REF_PAGE: u64 = 100; + +/// What `MainView` knows about the thread the card is for. +#[derive(Clone)] +pub struct Props { + pub cwd: String, + /// The project's own checkout, which decides what the worktree tag means. + pub project_cwd: String, + pub status: Option, + pub editors: Vec<&'static str>, + pub limits: Option, + pub thread_id: Option, + pub project_id: Option, + pub has_run: bool, + pub max_height: f32, +} + +enum Pending { + Refs { query: String, append: bool }, + Switch, + Create, + Pull, + Stacked, + Refresh, +} + +/// A git action in flight: the primary button shows its progress. +struct Run { + started: Instant, + progress: Progress, +} + +enum Notice { + Success { + title: String, + description: Option, + }, + Error(String), +} + +/// The Changes row asks the view to open the right panel's Diff, like the web's. +pub struct OpenDiff; + +impl EventEmitter for DetailsCard {} + +pub struct DetailsCard { + actions: ActionSender, + props: Option, + /// The editor last picked or launched, in place of the web's `t3code:last-editor`. + pick: Option, + editor_open: bool, + ref_open: bool, + git_open: bool, + search: Entity, + ref_scroll: ScrollHandle, + refs: Page, + refs_query: String, + refs_loading: bool, + refs_error: Option, + next_token: u64, + pending: HashMap, + run: Option, + notice: Option, + notice_epoch: u64, +} + +impl DetailsCard { + pub fn new(actions: ActionSender, window: &mut Window, cx: &mut Context) -> Self { + let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search refs...")); + cx.subscribe(&search, |this, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + this.query_changed(cx); + } + }) + .detach(); + Self { + actions, + props: None, + pick: None, + editor_open: false, + ref_open: false, + git_open: false, + search, + ref_scroll: ScrollHandle::new(), + refs: Page::default(), + refs_query: String::new(), + refs_loading: false, + refs_error: None, + next_token: 0, + pending: HashMap::new(), + run: None, + notice: None, + notice_epoch: 0, + } + } + + /// Takes the thread's current state; a new directory drops what was loaded for the last one. + pub fn sync(&mut self, props: Props) { + if self.props.as_ref().is_none_or(|old| old.cwd != props.cwd) { + self.refs = Page::default(); + self.refs_query.clear(); + self.refs_error = None; + self.notice = None; + self.close_popovers(); + } + self.props = Some(props); + } + + pub fn close_popovers(&mut self) { + self.editor_open = false; + self.ref_open = false; + self.git_open = false; + } + + fn preferred(&self) -> Option<&'static str> { + let props = self.props.as_ref()?; + details::preferred_editor(self.pick.as_deref(), &props.editors) + } + + fn open_in_editor(&mut self, editor: &'static str, cx: &mut Context) { + let Some(props) = &self.props else { return }; + self.pick = Some(editor.to_owned()); + let action = Action::OpenInEditor { + cwd: props.cwd.clone(), + editor: editor.to_owned(), + }; + if self.actions.try_send(action).is_err() { + eprintln!("[details] could not ask to open the editor"); + } + cx.notify(); + } + + /// The web's `editor.openFavorite`. + pub fn open_favorite(&mut self, cx: &mut Context) { + if let Some(editor) = self.preferred() { + self.open_in_editor(editor, cx); + } + } + + fn call(&mut self, call: Call, payload: Value, pending: Pending) { + self.next_token += 1; + let token = self.next_token; + self.pending.insert(token, pending); + let action = Action::Call { + token, + call, + payload, + }; + if self.actions.try_send(action).is_err() { + self.pending.remove(&token); + eprintln!("[details] could not send the request"); + } + } + + fn refresh_status(&mut self) { + let Some(cwd) = self.props.as_ref().map(|props| props.cwd.clone()) else { + return; + }; + self.call( + Call::RefreshStatus, + json!({"cwd": cwd, "localOnly": false}), + Pending::Refresh, + ); + } + + fn list_refs(&mut self, append: bool) { + let Some(cwd) = self.props.as_ref().map(|props| props.cwd.clone()) else { + return; + }; + let mut payload = json!({"cwd": cwd, "limit": REF_PAGE}); + if !self.refs_query.is_empty() { + payload["query"] = json!(self.refs_query); + } + if append { + let Some(cursor) = self.refs.next_cursor else { + return; + }; + payload["cursor"] = json!(cursor); + } + self.refs_loading = true; + self.refs_error = None; + let query = self.refs_query.clone(); + self.call(Call::ListRefs, payload, Pending::Refs { query, append }); + } + + fn query_changed(&mut self, cx: &mut Context) { + let query = self.search.read(cx).value().to_string(); + if query == self.refs_query { + return; + } + self.refs_query = query; + self.refs = Page::default(); + self.list_refs(false); + cx.notify(); + } + + fn set_ref_open(&mut self, open: bool, window: &mut Window, cx: &mut Context) { + self.ref_open = open; + if open { + self.refs_query.clear(); + self.refs = Page::default(); + self.search + .update(cx, |state, cx| state.set_value("", window, cx)); + self.list_refs(false); + } + cx.notify(); + } + + fn switch_ref(&mut self, name: String, cx: &mut Context) { + let Some(cwd) = self.props.as_ref().map(|props| props.cwd.clone()) else { + return; + }; + self.ref_open = false; + self.notice = None; + self.call( + Call::SwitchRef, + json!({"cwd": cwd, "refName": name}), + Pending::Switch, + ); + cx.notify(); + } + + fn create_ref(&mut self, name: String, cx: &mut Context) { + let Some(cwd) = self.props.as_ref().map(|props| props.cwd.clone()) else { + return; + }; + self.ref_open = false; + self.notice = None; + self.call( + Call::CreateRef, + json!({"cwd": cwd, "refName": name, "switchRef": true}), + Pending::Create, + ); + cx.notify(); + } + + fn start_run(&mut self, cx: &mut Context) { + self.notice = None; + self.run = Some(Run { + started: Instant::now(), + progress: Progress::default(), + }); + cx.spawn(async move |this, cx| { + loop { + cx.background_executor().timer(Duration::from_secs(1)).await; + let alive = this.update(cx, |this, cx| { + cx.notify(); + this.run.is_some() + }); + if !matches!(alive, Ok(true)) { + break; + } + } + }) + .detach(); + cx.notify(); + } + + fn finish(&mut self, outcome: Outcome, cx: &mut Context) { + self.run = None; + self.notice_epoch += 1; + let epoch = self.notice_epoch; + self.notice = Some(match outcome { + Outcome::Success { title, description } => { + cx.spawn(async move |this, cx| { + cx.background_executor().timer(SUCCESS_VISIBLE).await; + this.update(cx, |this, cx| { + if this.notice_epoch == epoch { + this.notice = None; + cx.notify(); + } + }) + .ok(); + }) + .detach(); + Notice::Success { title, description } + } + Outcome::Failed(message) => Notice::Error(message), + }); + self.refresh_status(); + cx.notify(); + } + + /// `runGitAction`: one `git.runStackedAction` stream, its progress shown on the primary button. + pub fn start_stacked( + &mut self, + action: GitAction, + message: Option, + feature_branch: bool, + file_paths: Option>, + cx: &mut Context, + ) { + let Some(props) = &self.props else { return }; + if self.run.is_some() { + return; + } + let mut payload = json!({ + "actionId": action_id(), + "cwd": props.cwd, + "action": action.wire(), + }); + if let Some(message) = message.filter(|message| !message.trim().is_empty()) { + payload["commitMessage"] = json!(message.trim()); + } + if feature_branch { + payload["featureBranch"] = json!(true); + } + if let Some(paths) = file_paths { + payload["filePaths"] = json!(paths); + } + if let Some(id) = &props.thread_id { + payload["threadId"] = json!(id); + } + if let Some(id) = &props.project_id { + payload["projectId"] = json!(id); + } + self.start_run(cx); + self.call(Call::RunStackedAction, payload, Pending::Stacked); + } + + fn start_pull(&mut self, cx: &mut Context) { + let Some(cwd) = self.props.as_ref().map(|props| props.cwd.clone()) else { + return; + }; + if self.run.is_some() { + return; + } + self.start_run(cx); + if let Some(run) = &mut self.run { + run.progress.phase = Some("Pulling latest changes...".to_owned()); + } + self.call(Call::Pull, json!({"cwd": cwd}), Pending::Pull); + } + + /// The outcome of a call the card sent. + pub fn on_call(&mut self, token: u64, event: CallEvent, cx: &mut Context) { + match event { + CallEvent::Progress(value) => { + if matches!(self.pending.get(&token), Some(Pending::Stacked)) + && let Some(run) = &mut self.run + && let Some(outcome) = run.progress.apply(&value) + { + self.pending.remove(&token); + self.finish(outcome, cx); + return; + } + } + CallEvent::Done(value) => match self.pending.remove(&token) { + Some(Pending::Refs { query, append }) => { + if query == self.refs_query { + let page = Page::decode(&value); + if append { + self.refs.refs.extend(page.refs); + self.refs.total = page.total; + self.refs.has_more = page.has_more; + self.refs.next_cursor = page.next_cursor; + } else { + self.refs = page; + } + self.refs_loading = false; + } + } + Some(Pending::Switch | Pending::Create) => self.refresh_status(), + Some(Pending::Pull) => { + self.finish(git_actions::pull_outcome(&value), cx); + return; + } + Some(Pending::Stacked) => { + if self.run.is_some() { + self.finish(Progress::finished(&value), cx); + return; + } + } + Some(Pending::Refresh) | None => {} + }, + CallEvent::Failed(message) => match self.pending.remove(&token) { + Some(Pending::Refs { query, .. }) => { + if query == self.refs_query { + self.refs_loading = false; + self.refs_error = Some(message); + } + } + Some(Pending::Switch | Pending::Create) => { + self.notice = Some(Notice::Error(message)); + } + Some(Pending::Pull | Pending::Stacked) => { + self.finish(Outcome::Failed(message), cx); + return; + } + Some(Pending::Refresh) | None => {} + }, + } + cx.notify(); + } + + /// A quick or menu action: the default ref confirms first, a commit with a dialog. + fn request(&mut self, action: GitAction, window: &mut Window, cx: &mut Context) { + let Some(props) = self.props.clone() else { + return; + }; + let Some(status) = &props.status else { return }; + let local = &status.local; + if git_actions::requires_default_branch_confirmation(action, local.is_default_ref) { + let includes_commit = local.has_working_tree_changes + && matches!(action, GitAction::CommitPush | GitAction::CommitPushPr); + let branch = local.ref_name.clone().unwrap_or_default(); + let copy = git_actions::default_branch_dialog_copy( + action, + &branch, + includes_commit, + Terminology::of(Some(status)), + ); + let unavailable = + git_actions::feature_branch_unavailable_reason(&branch, includes_commit); + self.open_default_branch_dialog(action, copy, unavailable, window, cx); + return; + } + self.start_stacked(action, None, false, None, cx); + } + + fn open_default_branch_dialog( + &mut self, + action: GitAction, + copy: git_actions::DialogCopy, + unavailable: Option, + window: &mut Window, + cx: &mut Context, + ) { + let card = cx.weak_entity(); + window.open_dialog(cx, move |dialog, _, cx| { + let muted = cx.theme().muted_foreground; + let run = |feature_branch: bool| { + let card = card.clone(); + move |_: &_, window: &mut Window, cx: &mut App| { + window.close_dialog(cx); + card.update(cx, |card, cx| { + card.start_stacked(action, None, feature_branch, None, cx) + }) + .ok(); + } + }; + dialog.title(copy.title.clone()).w(px(440.)).child( + v_flex() + .gap_3() + .child( + div() + .text_sm() + .text_color(muted) + .child(copy.description.clone()), + ) + .children( + unavailable + .clone() + .map(|reason| div().text_xs().text_color(muted).child(reason)), + ) + .child( + h_flex() + .gap_2() + .justify_end() + .flex_wrap() + .child( + Button::new("default-ref-abort") + .label("Abort") + .outline() + .on_click(|_, window, cx| window.close_dialog(cx)), + ) + .child( + Button::new("default-ref-continue") + .label(copy.continue_label.clone()) + .outline() + .on_click(run(false)), + ) + .child( + Button::new("default-ref-feature") + .label("Check out feature branch & continue") + .primary() + .disabled(unavailable.is_some()) + .on_click(run(true)), + ), + ), + ) + }); + } + + fn open_commit_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let Some(status) = self.props.as_ref().and_then(|props| props.status.clone()) else { + return; + }; + let card = cx.weak_entity(); + let message = cx.new(|cx| { + TextareaState::new(window, cx) + .placeholder("Leave empty to auto-generate") + .auto_grow(3, 8) + }); + let state = cx.new(|_| CommitDialog { + card, + branch: status.local.ref_name.clone(), + is_default: status.local.is_default_ref, + files: status.local.files.clone(), + excluded: HashSet::new(), + editing: false, + message, + }); + window.open_dialog(cx, move |dialog, _, _| { + dialog + .title("Commit changes") + .w(px(480.)) + .child(state.clone()) + }); + } + + fn pick_menu(&mut self, id: MenuId, window: &mut Window, cx: &mut Context) { + self.git_open = false; + cx.notify(); + match id { + MenuId::Commit => self.open_commit_dialog(window, cx), + MenuId::Push => self.request(GitAction::Push, window, cx), + MenuId::Pr => self.request(GitAction::CreatePr, window, cx), + } + } +} + +/// A random version 4 UUID for `actionId`. +fn action_id() -> String { + let mut bytes = [0u8; 16]; + if getrandom::fill(&mut bytes).is_err() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_nanos()); + bytes = nanos.to_le_bytes(); + } + bytes[6] = bytes[6] & 0x0f | 0x40; + bytes[8] = bytes[8] & 0x3f | 0x80; + let hex: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + format!( + "{}-{}-{}-{}-{}", + &hex[0..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..32] + ) +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_millis() as i64) +} + +fn tip(text: impl Into) -> impl Fn(&mut Window, &mut App) -> gpui::AnyView { + let text = text.into(); + move |window, cx| { + Tooltip::new(text.clone()) + .text_xs() + .py_1() + .rounded(px(6.)) + .build(window, cx) + } +} + +/// The panel row hover fill: white 8% in dark, black 3.5% in light. +fn hover_fill(dark: bool) -> Hsla { + if dark { + gpui::white().opacity(0.08) + } else { + gpui::black().opacity(0.035) + } +} + +type TriggerBuild = Box AnyElement>; + +/// A popover trigger styled by the card, which knows whether the popover is open. +#[derive(IntoElement)] +struct Trigger { + open: bool, + build: Option, +} + +impl Trigger { + fn new(build: impl FnOnce(bool, &App) -> AnyElement + 'static) -> Self { + Self { + open: false, + build: Some(Box::new(build)), + } + } +} + +impl Selectable for Trigger { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } + + fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + fn is_open(&self) -> bool { + self.open + } +} + +impl RenderOnce for Trigger { + fn render(mut self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let build = self.build.take().expect("a trigger renders once"); + build(self.open, cx) + } +} + +/// The chevron segment of a split button. +fn chevron(id: &'static str, label: &'static str, open: bool, cx: &App) -> AnyElement { + let theme = cx.theme(); + let (muted, foreground) = (theme.muted_foreground, theme.foreground); + div() + .id(id) + .flex() + .items_center() + .justify_center() + .w_8() + .h_full() + .flex_shrink_0() + .cursor_pointer() + .text_color(if open { foreground } else { muted }) + .tooltip(tip(label)) + .child(Icon::new(Icons::ChevronDown).size_4()) + .into_any_element() +} + +/// A `h-8` panel row: the primary segment, a separator on hover, and the chevron. +fn split_row( + group: &'static str, + primary: impl IntoElement, + chevron: impl IntoElement, + cx: &App, +) -> gpui::Div { + let theme = cx.theme(); + let (fill, separator) = (hover_fill(theme.is_dark()), theme.border); + h_flex() + .group(group) + .h_8() + .rounded(px(8.)) + .hover(move |style| style.bg(fill)) + .child(primary) + .child( + div() + .w(px(1.)) + .h_4() + .flex_shrink_0() + .bg(separator) + .opacity(0.) + .group_hover(group, |style| style.opacity(1.)), + ) + .child(chevron) +} + +/// The primary segment of a split button: icon, label, `flex-1`. +fn primary_segment( + id: &'static str, + icon: impl IntoElement, + label: String, + cx: &App, +) -> gpui::Stateful { + let foreground = cx.theme().foreground; + h_flex() + .id(id) + .flex_1() + .min_w_0() + .h_full() + .px(px(10.)) + .gap(px(10.)) + .rounded_l(px(8.)) + .cursor_pointer() + .text_size(px(13.)) + .font_weight(FontWeight::MEDIUM) + .text_color(foreground.opacity(0.8)) + .child(icon) + .child(div().min_w_0().truncate().child(label)) +} + +/// A plain `h-8` panel row. +fn panel_row(id: &'static str, cx: &App) -> gpui::Stateful { + let theme = cx.theme(); + let (fill, foreground) = (hover_fill(theme.is_dark()), theme.foreground); + h_flex() + .id(id) + .h_8() + .px(px(10.)) + .gap(px(10.)) + .rounded(px(8.)) + .text_size(px(13.)) + .font_weight(FontWeight::MEDIUM) + .text_color(foreground.opacity(0.8)) + .hover(move |style| style.bg(fill)) +} + +fn row_icon(icon: Icons, cx: &App) -> Icon { + Icon::new(icon) + .size_4() + .flex_shrink_0() + .text_color(cx.theme().muted_foreground) +} + +fn editor_mark(editor: &str, cx: &App) -> AnyElement { + match editor_icon(editor, cx.theme().is_dark()) { + Some(path) => img(path).size_4().flex_shrink_0().into_any_element(), + None => row_icon(Icons::SquareArrowOutUpRight, cx).into_any_element(), + } +} + +fn quick_icon(kind: QuickKind, label: &str) -> Icons { + match kind { + QuickKind::Run(GitAction::Commit) => Icons::GitCommitHorizontal, + QuickKind::Run(GitAction::Push | GitAction::CommitPush) | QuickKind::Publish => { + Icons::CloudUpload + } + QuickKind::Run(GitAction::CreatePr | GitAction::CommitPushPr) => Icons::GitPullRequest, + QuickKind::Pull => Icons::CloudDownload, + QuickKind::Hint => match label { + "Commit" => Icons::GitCommitHorizontal, + "Push" => Icons::CloudUpload, + _ => Icons::Info, + }, + } +} + +fn menu_icon(id: MenuId) -> Icons { + match id { + MenuId::Commit => Icons::GitCommitHorizontal, + MenuId::Push => Icons::CloudUpload, + MenuId::Pr => Icons::GitPullRequest, + } +} + +impl DetailsCard { + fn open_in_section( + &self, + editors: &[&'static str], + cx: &mut Context, + ) -> impl IntoElement { + let preferred = self.preferred(); + let view = cx.entity(); + let label = preferred.map_or("Open in".to_owned(), |id| { + format!("Open in {}", details::editor_label(id)) + }); + let icon = match preferred { + Some(id) => editor_mark(id, cx), + None => row_icon(Icons::SquareArrowOutUpRight, cx).into_any_element(), + }; + let primary = primary_segment("open-in-editor", icon, label, cx) + .on_click(cx.listener(|this, _, _, cx| this.open_favorite(cx))); + let editors = editors.to_vec(); + let open = self.editor_open; + let popover = Popover::new("editor-menu") + .anchor(Anchor::TopRight) + .open(open) + .p_0() + .on_open_change({ + let view = view.clone(); + move |open, _, cx| { + view.update(cx, |this, cx| { + this.editor_open = *open; + cx.notify(); + }); + } + }) + .trigger(Trigger::new(move |open, cx| { + chevron("editor-menu-trigger", "Choose editor", open, cx) + })) + .content(move |_, _, cx| { + let theme = cx.theme(); + let (foreground, muted, hover) = + (theme.foreground, theme.muted_foreground, theme.accent); + v_flex() + .w(px(240.)) + .p_1() + .children(editors.iter().map(|&id| { + let view = view.clone(); + h_flex() + .id(id) + .h_8() + .px_2() + .gap_2() + .rounded(px(6.)) + .cursor_pointer() + .text_sm() + .text_color(foreground) + .hover(move |style| style.bg(hover)) + .child(editor_mark(id, cx)) + .child(div().flex_1().truncate().child(details::editor_label(id))) + .when(preferred == Some(id), |row| { + row.child( + div() + .text_xs() + .text_color(muted) + .child(details::OPEN_FAVORITE_SHORTCUT), + ) + }) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| { + this.editor_open = false; + this.open_in_editor(id, cx); + }); + }) + })) + }); + split_row("open-in-split", primary, popover, cx) + } + + fn branch_row(&self, status: &VcsStatus, cx: &mut Context) -> AnyElement { + let Some(props) = self.props.clone() else { + return div().into_any_element(); + }; + let view = cx.entity(); + let label = status + .local + .ref_name + .clone() + .unwrap_or_else(|| "Select ref".to_owned()); + let snapshot = RefPicker { + query: self.refs_query.clone(), + page: self.refs.clone(), + loading: self.refs_loading, + error: self.refs_error.clone(), + cwd: props.cwd, + project_cwd: props.project_cwd, + search: self.search.clone(), + scroll: self.ref_scroll.clone(), + }; + Popover::new("ref-picker") + .anchor(Anchor::TopRight) + .open(self.ref_open) + .p_0() + .on_open_change({ + let view = view.clone(); + move |open, window, cx| { + view.update(cx, |this, cx| this.set_ref_open(*open, window, cx)); + } + }) + .trigger(Trigger::new(move |_, cx| { + panel_row("branch-trigger", cx) + .w_full() + .cursor_pointer() + .child(row_icon(Icons::GitBranch, cx)) + .child(div().flex_1().min_w_0().truncate().child(label)) + .into_any_element() + })) + .content(move |_, window, cx| snapshot.render(view.clone(), window, cx)) + .into_any_element() + } + + fn git_row(&self, status: &VcsStatus, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let (muted, danger, foreground) = (theme.muted_foreground, theme.danger, theme.foreground); + if let Some(run) = &self.run { + let seconds = run.started.elapsed().as_secs(); + return v_flex() + .min_h_8() + .px(px(10.)) + .py_1() + .justify_center() + .text_size(px(13.)) + .child( + h_flex() + .gap(px(10.)) + .text_color(foreground.opacity(0.8)) + .child(row_icon(Icons::CircleDotDashed, cx)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .child(run.progress.label()), + ) + .child( + div() + .text_xs() + .text_color(muted) + .child(git_actions::format_elapsed(seconds)), + ), + ) + .children(run.progress.output.clone().map(|line| { + div() + .pl(px(26.)) + .text_xs() + .text_color(muted) + .truncate() + .child(line) + })) + .into_any_element(); + } + match &self.notice { + Some(Notice::Success { title, description }) => { + let row = panel_row("git-success", cx) + .child(Icon::new(Icons::Check).size_4().text_color(foreground)) + .child(div().flex_1().min_w_0().truncate().child(title.clone())); + return match description { + Some(text) => row.tooltip(tip(text.clone())).into_any_element(), + None => row.into_any_element(), + }; + } + Some(Notice::Error(message)) => { + let view = cx.entity(); + return panel_row("git-error", cx) + .cursor_pointer() + .text_color(danger) + .child(Icon::new(Icons::CircleAlert).size_4().text_color(danger)) + .child(div().flex_1().min_w_0().truncate().child(message.clone())) + .tooltip(tip(message.clone())) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| { + this.notice = None; + cx.notify(); + }); + }) + .into_any_element(); + } + None => {} + } + let local = &status.local; + let busy = self.run.is_some(); + let quick = git_actions::resolve_quick_action( + Some(status), + busy, + local.is_default_ref, + local.has_primary_remote, + ); + // Publishing a repository needs the hosting flow, which this client does not have. + let unsupported = quick.kind == QuickKind::Publish; + let disabled = quick.disabled || unsupported; + let hint = if unsupported { + Some("Publishing a repository is not supported in this client yet.".to_owned()) + } else { + quick.hint.clone() + }; + let icon = row_icon(quick_icon(quick.kind, &quick.label), cx); + let mut primary = primary_segment("git-quick-action", icon, quick.label.clone(), cx); + if disabled { + primary = primary.opacity(0.5).cursor_default(); + if let Some(hint) = hint { + primary = primary.tooltip(tip(hint)); + } + } else { + let kind = quick.kind; + primary = primary.on_click(cx.listener(move |this, _, window, cx| match kind { + QuickKind::Run(action) => this.request(action, window, cx), + QuickKind::Pull => this.start_pull(cx), + QuickKind::Publish | QuickKind::Hint => {} + })); + } + let items = git_actions::build_menu_items(Some(status), busy, local.has_primary_remote); + let warning = menu_warning(status); + let view = cx.entity(); + let popover = Popover::new("git-menu") + .anchor(Anchor::TopRight) + .open(self.git_open) + .p_0() + .on_open_change({ + let view = view.clone(); + move |open, _, cx| { + view.update(cx, |this, cx| { + this.git_open = *open; + if *open { + this.refresh_status(); + } + cx.notify(); + }); + } + }) + .trigger(Trigger::new(move |open, cx| { + chevron("git-menu-trigger", "Git actions", open, cx) + })) + .content(move |_, _, cx| { + let theme = cx.theme(); + let (foreground, muted, hover) = + (theme.foreground, theme.muted_foreground, theme.accent); + v_flex() + .w(px(240.)) + .p_1() + .children(items.iter().map(|item| { + let (view, id, disabled) = (view.clone(), item.id, item.disabled); + h_flex() + .id(match item.id { + MenuId::Commit => "git-menu-commit", + MenuId::Push => "git-menu-push", + MenuId::Pr => "git-menu-pr", + }) + .h_8() + .px_2() + .gap_2() + .rounded(px(6.)) + .text_sm() + .text_color(foreground) + .when(disabled, |row| row.opacity(0.5)) + .when(!disabled, |row| { + row.cursor_pointer() + .hover(move |style| style.bg(hover)) + .on_click(move |_, window, cx| { + view.update(cx, |this, cx| this.pick_menu(id, window, cx)); + }) + }) + .child(Icon::new(menu_icon(item.id)).size_4().text_color(muted)) + .child(div().flex_1().truncate().child(item.label.clone())) + })) + .children(warning.clone().map(|text| { + div() + .px_2() + .py_1() + .text_xs() + .text_color(theme.warning) + .child(text) + })) + }); + split_row("git-split", primary, popover, cx).into_any_element() + } + + fn changes_row(&self, status: &VcsStatus, cx: &mut Context) -> impl IntoElement { + let totals = status.changes_totals(); + let opens_diff = self + .props + .as_ref() + .is_some_and(|props| props.thread_id.is_some()); + let open_diff = cx.listener(|_, _, _, cx| cx.emit(OpenDiff)); + let theme = cx.theme(); + let (muted, mono) = (theme.muted_foreground, theme.mono_font_family.clone()); + let count = |sign: char, count: u64, tint: Hsla| { + div() + .text_color(if count == 0 { muted } else { tint }) + .child(format!("{sign}{}", details::format_diff_count(count))) + }; + panel_row("changes-row", cx) + .when(opens_diff, |row| row.cursor_pointer().on_click(open_diff)) + .child(row_icon(Icons::FileDiff, cx)) + .child(div().flex_shrink_0().child("Changes")) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_xs() + .text_color(muted) + .children( + status + .local + .base_ref + .as_ref() + .map(|base| format!("vs {base}")), + ), + ) + .child( + h_flex() + .flex_shrink_0() + .gap_1() + .text_xs() + .font_family(mono) + .child(count('+', totals.insertions, gpui::rgb(0x22c55e).into())) + .child(count('-', totals.deletions, gpui::rgb(0xef4444).into())), + ) + } + + fn usage_section(&self, limits: &Limits, cx: &App) -> AnyElement { + let theme = cx.theme(); + let (muted, foreground) = (theme.muted_foreground, theme.foreground); + let (danger, warning, fill) = (theme.danger, theme.warning, hover_fill(theme.is_dark())); + let row = |id: usize| { + h_flex() + .id(("usage", id)) + .h_6() + .px(px(10.)) + .gap_2() + .rounded(px(6.)) + .text_xs() + .hover(move |style| style.bg(fill)) + }; + if let Some(notice) = limits.notice() { + return v_flex() + .child( + row(0) + .text_color(muted) + .tooltip(tip(notice)) + .child(div().flex_1().child("Limits")) + .child("Unavailable"), + ) + .into_any_element(); + } + let now = now_ms(); + v_flex() + .children(limits.windows.iter().enumerate().map(|(index, window)| { + let left = window.remaining_percent(); + let bar = match usage::tone(left) { + Some(Tone::Error) => danger, + Some(Tone::Warning) => warning, + None => foreground.opacity(0.8), + }; + row(index) + .tooltip(tip(window.tooltip(now).join("\n"))) + .child( + div() + .w(px(112.)) + .flex_shrink_0() + .truncate() + .text_color(muted) + .child(window.label.clone()), + ) + .child( + div() + .flex_1() + .min_w_0() + .h(px(3.)) + .rounded_full() + .bg(muted.opacity(0.3)) + .child( + div() + .h_full() + .rounded_full() + .bg(bar) + .min_w(px(6.)) + .w(relative(left.clamp(0, 100) as f32 / 100.)), + ), + ) + .child( + div() + .w(px(32.)) + .flex_shrink_0() + .text_right() + .text_color(foreground.opacity(0.8)) + .child(format!("{left}%")), + ) + })) + .into_any_element() + } +} + +/// The warning under the menu: a detached HEAD or a branch behind its upstream. +fn menu_warning(status: &VcsStatus) -> Option { + let terminology = Terminology::of(Some(status)); + if status.local.ref_name.is_none() { + return Some(format!( + "Detached HEAD: create and check out a branch to enable push and {} actions.", + terminology.singular + )); + } + (status.remote.behind > 0).then(|| "Behind upstream. Pull/rebase first.".to_owned()) +} + +/// What the ref picker's popover draws, snapshotted at each render of the card. +struct RefPicker { + query: String, + page: Page, + loading: bool, + error: Option, + cwd: String, + project_cwd: String, + search: Entity, + scroll: ScrollHandle, +} + +impl RefPicker { + fn render(&self, view: Entity, window: &mut Window, cx: &mut App) -> AnyElement { + let theme = cx.theme(); + let (muted, border, ring) = (theme.muted_foreground, theme.border, theme.ring); + let (hover, selected) = (theme.list_hover, theme.list_active); + let searching = self.search.focus_handle(cx).is_focused(window); + let sanitized = refs::sanitize_new_ref_name(&self.query); + let creatable = + !sanitized.is_empty() && !self.page.refs.iter().any(|r| r.name == sanitized); + let rows = self.page.refs.iter().enumerate().map(|(index, entry)| { + let tag = refs::tag(entry, &self.project_cwd); + let elsewhere = !entry.current + && entry + .worktree_path + .as_deref() + .is_some_and(|path| path != self.cwd); + let (view, name) = (view.clone(), entry.name.clone()); + let current = entry.current; + let label = match refs::split_for_middle_truncate(&entry.name, None) { + Some((head, tail)) => h_flex() + .flex_1() + .min_w_0() + .child(div().min_w_0().truncate().child(head)) + .child(div().flex_shrink_0().child(tail)) + .into_any_element(), + None => div() + .flex_1() + .min_w_0() + .truncate() + .child(entry.name.clone()) + .into_any_element(), + }; + h_flex() + .id(("ref", index)) + .gap_2() + .px_2() + .py_1p5() + .items_center() + .rounded_md() + .text_sm() + .when(current, |row| row.bg(selected)) + .when(elsewhere, |row| { + row.opacity(0.5) + .tooltip(tip("This ref is checked out in another worktree.")) + }) + .when(!elsewhere, |row| { + row.cursor_pointer() + .hover(move |style| style.bg(hover)) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| { + if current { + this.ref_open = false; + cx.notify(); + } else { + this.switch_ref(name.clone(), cx); + } + }); + }) + }) + .child(label) + .children(tag.map(|tag: Tag| { + div() + .flex_shrink_0() + .text_xs() + .text_color(muted) + .child(tag.label()) + })) + }); + let create = creatable.then(|| { + let (view, name) = (view.clone(), sanitized.clone()); + h_flex() + .id("ref-create") + .gap_2() + .px_2() + .py_1p5() + .items_center() + .rounded_md() + .text_sm() + .cursor_pointer() + .hover(move |style| style.bg(hover)) + .child(Icon::new(Icons::GitBranchPlus).size_4().text_color(muted)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .child(format!("Create new ref \"{sanitized}\"")), + ) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| this.create_ref(name.clone(), cx)); + }) + }); + let more = (self.page.has_more && !self.loading).then(|| view.clone()); + let scroll = self.scroll.clone(); + let footer = self.page.status(self.loading); + let empty = self.page.refs.is_empty() && !self.loading && create.is_none(); + v_flex() + .w(px(288.)) + .rounded_lg() + .overflow_hidden() + .child( + div().px_3().pt(px(10.)).child( + h_flex() + .pb(px(6.)) + .border_b_1() + .border_color(if searching { ring } else { border.opacity(0.7) }) + .child( + Input::new(&self.search) + .appearance(false) + .h(px(26.)) + .px_0() + .prefix( + Icon::new(IconName::Search) + .size_4() + .text_color(muted.opacity(0.55)), + ), + ), + ), + ) + .child( + v_flex() + .id("ref-list") + .max_h(px(280.)) + .overflow_y_scroll() + .track_scroll(&scroll) + .on_scroll({ + let scroll = scroll.clone(); + move |_, _, cx| { + let Some(view) = &more else { return }; + let offset = scroll.offset().y; + let remaining = scroll.max_offset().y + offset; + if remaining < px(160.) { + view.update(cx, |this, cx| { + if !this.refs_loading { + this.list_refs(true); + cx.notify(); + } + }); + } + } + }) + .p_1() + .children(create) + .children(rows) + .when(empty, |list| { + list.child( + div().p_2().text_sm().text_color(muted).child( + self.error + .clone() + .unwrap_or_else(|| "No matching refs.".to_owned()), + ), + ) + }), + ) + .children(footer.map(|text| { + div() + .px_3() + .py_1p5() + .border_t_1() + .border_color(border) + .text_xs() + .text_color(muted) + .child(text) + })) + .into_any_element() + } +} + +impl Render for DetailsCard { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(props) = self.props.clone() else { + return div().into_any_element(); + }; + let status = props.status.as_ref(); + let usage_rows = props.limits.as_ref().map_or(0, |limits| { + if limits.notice().is_some() { + 1 + } else { + limits.windows.len() + } + }); + let inputs = Inputs { + has_cwd: true, + is_repo: status.is_some_and(|status| status.local.is_repo), + has_editors: !props.editors.is_empty(), + quiet_empty_draft: details::quiet_empty_draft(props.has_run, status), + usage_rows, + }; + let density = details::density_for(props.max_height, inputs); + let show = Show::resolve(density, inputs); + if show.is_empty() { + return div().into_any_element(); + } + let mut sections: Vec = Vec::new(); + if show.open_in { + sections.push(self.open_in_section(&props.editors, cx).into_any_element()); + } + if let Some(status) = status + && (show.branch || show.git_actions || show.changes) + { + let mut rows = v_flex(); + if show.branch { + rows = rows.child(self.branch_row(status, cx)); + } + if show.git_actions { + rows = rows.child(self.git_row(status, cx)); + } + if show.changes { + rows = rows.child(self.changes_row(status, cx)); + } + sections.push(rows.into_any_element()); + } + if let (true, Some(limits)) = (show.usage, props.limits.as_ref()) { + sections.push(self.usage_section(limits, cx)); + } + let theme = cx.theme(); + let divider = theme.border; + let last = sections.len().saturating_sub(1); + let body = sections.into_iter().enumerate().map(|(index, section)| { + v_flex() + .pt_2() + .pb(px(10.)) + .px_2() + .when(density == Density::Full && index < last, |wrap| { + wrap.border_b_1().border_color(divider) + }) + .child(section) + }); + div() + .id("thread-details-card") + .w(px(details::CARD_WIDTH)) + .max_h(px(props.max_height)) + .overflow_y_scroll() + .rounded(px(16.)) + .border_1() + .border_color(gpui::white().opacity(0.05)) + .bg(theme.secondary.opacity(0.8)) + .shadow_lg() + .children(body) + .into_any_element() + } +} + +/// The web's `CommitDialog` body: the branch, the files to include, and the message. +struct CommitDialog { + card: WeakEntity, + branch: Option, + is_default: bool, + files: Vec, + excluded: HashSet, + editing: bool, + message: Entity, +} + +impl CommitDialog { + fn selected(&self) -> Vec<&ChangedFile> { + self.files + .iter() + .filter(|file| !self.excluded.contains(&file.path)) + .collect() + } + + fn submit(&self, feature_branch: bool, window: &mut Window, cx: &mut App) { + let paths = (!self.excluded.is_empty()).then(|| { + self.selected() + .iter() + .map(|file| file.path.clone()) + .collect() + }); + let message = self.message.read(cx).value().to_string(); + window.close_dialog(cx); + self.card + .update(cx, |card, cx| { + card.start_stacked(GitAction::Commit, Some(message), feature_branch, paths, cx) + }) + .ok(); + } +} + +impl Render for CommitDialog { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let (muted, warning, border) = (theme.muted_foreground, theme.warning, theme.border); + let mono = theme.mono_font_family.clone(); + let selected = self.selected(); + let none_selected = selected.is_empty(); + let lines = selected.iter().fold((0, 0), |(ins, del), file| { + (ins + file.lines.insertions, del + file.lines.deletions) + }); + let total = if none_selected { + "none".to_owned() + } else { + format!( + "{} of {} files +{} -{}", + selected.len(), + self.files.len(), + details::format_diff_count(lines.0), + details::format_diff_count(lines.1) + ) + }; + let editing = self.editing; + let entity = cx.entity(); + let files = self.files.iter().enumerate().map(|(index, file)| { + let (entity, path) = (entity.clone(), file.path.clone()); + let included = !self.excluded.contains(&file.path); + h_flex() + .gap_2() + .h_6() + .text_xs() + .when(editing, |row| { + row.child( + Checkbox::new(("commit-file", index)) + .checked(included) + .on_click(move |_, _, cx| { + entity.update(cx, |this, cx| { + if !this.excluded.remove(&path) { + this.excluded.insert(path.clone()); + } + cx.notify(); + }); + }), + ) + }) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .font_family(mono.clone()) + .when(!included, |label| label.text_color(muted)) + .child(file.path.clone()), + ) + .child(div().text_color(gpui::rgb(0x22c55e)).child(format!( + "+{}", + details::format_diff_count(file.lines.insertions) + ))) + .child(div().text_color(gpui::rgb(0xef4444)).child(format!( + "-{}", + details::format_diff_count(file.lines.deletions) + ))) + }); + let toggle = entity.clone(); + v_flex() + .gap_3() + .child(div().text_sm().text_color(muted).child( + "Review and confirm your commit. Leave the message blank to auto-generate one.", + )) + .child( + v_flex() + .gap_2() + .p_3() + .rounded_lg() + .border_1() + .border_color(border) + .text_sm() + .child( + h_flex() + .gap_2() + .child(div().text_color(muted).child("Branch")) + .child( + div().font_weight(FontWeight::MEDIUM).child( + self.branch + .clone() + .unwrap_or_else(|| "(detached HEAD)".to_owned()), + ), + ) + .when(self.is_default, |row| { + row.child( + div().text_xs().text_color(warning).child("Default branch"), + ) + }), + ) + .child( + h_flex() + .gap_2() + .child(div().text_color(muted).child("Files")) + .child(div().flex_1().text_xs().text_color(muted).child(total)) + .when(self.files.len() > 1, |row| { + row.child( + Button::new("commit-edit") + .label(if editing { "Done" } else { "Edit" }) + .ghost() + .xsmall() + .on_click(move |_, _, cx| { + toggle.update(cx, |this, cx| { + this.editing = !this.editing; + cx.notify(); + }); + }), + ) + }), + ) + .child( + v_flex() + .id("commit-files") + .max_h(px(200.)) + .overflow_y_scroll() + .children(files), + ), + ) + .child( + v_flex() + .gap_1() + .child( + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .child("Commit message (optional)"), + ) + .child(Textarea::new(&self.message)), + ) + .child( + h_flex() + .gap_2() + .justify_end() + .child( + Button::new("commit-cancel") + .label("Cancel") + .outline() + .on_click(|_, window, cx| window.close_dialog(cx)), + ) + .child( + Button::new("commit-new-branch") + .label("Commit on new branch") + .outline() + .disabled(none_selected) + .on_click( + cx.listener(|this, _, window, cx| this.submit(true, window, cx)), + ), + ) + .child( + Button::new("commit-confirm") + .label("Commit") + .primary() + .disabled(none_selected) + .on_click( + cx.listener(|this, _, window, cx| this.submit(false, window, cx)), + ), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn action_ids_are_version_four_uuids() { + let id = action_id(); + let parts: Vec<&str> = id.split('-').collect(); + assert_eq!( + parts.iter().map(|part| part.len()).collect::>(), + [8, 4, 4, 4, 12] + ); + assert!(parts[2].starts_with('4')); + assert!("89ab".contains(parts[3].chars().next().unwrap())); + assert_ne!(id, action_id()); + } +} diff --git a/apps/desktop-gpui/src/git_actions.rs b/apps/desktop-gpui/src/git_actions.rs index d51c427843d0..12f0b318bf97 100644 --- a/apps/desktop-gpui/src/git_actions.rs +++ b/apps/desktop-gpui/src/git_actions.rs @@ -412,7 +412,11 @@ mod tests { fn progress_follows_the_stream() { let mut progress = Progress::default(); assert_eq!(progress.label(), Progress::STARTING); - assert!(progress.apply(&json!({"kind": "phase_started", "label": "Pushing..."})).is_none()); + assert!( + progress + .apply(&json!({"kind": "phase_started", "label": "Pushing..."})) + .is_none() + ); assert_eq!(progress.label(), "Pushing..."); progress.apply(&json!({"kind": "hook_started", "hookName": "pre-push"})); assert_eq!(progress.label(), "Running pre-push..."); @@ -442,7 +446,9 @@ mod tests { fn elapsed_and_pull_wording() { assert_eq!(format_elapsed(9), "9s"); assert_eq!(format_elapsed(75), "1m 15s"); - let pulled = pull_outcome(&json!({"status": "pulled", "refName": "main", "upstreamRef": "origin/main"})); + let pulled = pull_outcome( + &json!({"status": "pulled", "refName": "main", "upstreamRef": "origin/main"}), + ); assert_eq!( pulled, Outcome::Success { diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 0f9681d6f7d3..f1a6c354c79f 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -4,6 +4,7 @@ mod catalog; mod config; mod connection; mod details; +mod details_card; mod diff; mod electron_dpop; mod environments; diff --git a/apps/desktop-gpui/src/shell.rs b/apps/desktop-gpui/src/shell.rs index d5083c5f5cde..4db9ec3edfda 100644 --- a/apps/desktop-gpui/src/shell.rs +++ b/apps/desktop-gpui/src/shell.rs @@ -320,7 +320,7 @@ fn pr_badge(thread: &Thread) -> Option { state: only .snapshot .as_ref() - .map_or(PrState::Pending, |_| state_of(&only)), + .map_or(PrState::Pending, |_| state_of(only)), stack: false, text: only.number.to_string(), }); diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index df5162da26f6..31d0270589dc 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -1,6 +1,7 @@ use crate::assets::{MESSAGE_SQUARE_DASHED, STAGE_DEV, STAGE_DEV_BUTTON, provider_icon}; use crate::connection::{Action, ActionSender}; -use crate::details::{self, Density, Inputs, Show, VcsStatus}; +use crate::details::{self, VcsStatus}; +use crate::details_card::{DetailsCard, OpenDiff, Props}; use crate::history::History; use crate::minimap; use crate::projects::{self, EnvironmentProjects, MachineKind, ProjectEntry}; @@ -219,10 +220,6 @@ pub struct MainView { /// Directories whose diff was asked for on this connection. diffs_requested: HashSet, diff_list: Entity, - /// The editor last picked or launched, in place of the web's `t3code:last-editor`. - editor_pick: Option, - editor_menu_open: bool, - editor_menu: Entity, /// Threads (and drafts, by project) whose terminal drawer is open; it starts closed. terminal_open: HashSet, /// The drawer's height per thread, as dragged. @@ -230,6 +227,7 @@ pub struct MainView { terminal: Entity, /// The terminal the connection was last asked to attach. terminal_attached: Option, + card: Entity, /// The main area's width at the last render, for fitting the details card. main_width: Cell, actions: ActionSender, @@ -264,18 +262,15 @@ impl MainView { }) .detach(); let view = cx.weak_entity(); - let menu_view = view.clone(); let permissions = cx.new(|_| PermissionsPanel { view, selected: RuntimeMode::FullAccess, preview: None, }); - let editor_menu = cx.new(|_| EditorMenu { - view: menu_view, - editors: Vec::new(), - preferred: None, - }); let terminal_actions = actions.clone(); + let card = cx.new(|cx| DetailsCard::new(actions.clone(), window, cx)); + cx.subscribe(&card, |this, _, _: &OpenDiff, cx| this.open_diff(cx)) + .detach(); Self { status, search: cx.new(|cx| InputState::new(window, cx).placeholder("Search")), @@ -317,13 +312,11 @@ impl MainView { diffs: HashMap::new(), diffs_requested: HashSet::new(), diff_list: cx.new(|_| right_panel::DiffList::new()), - editor_pick: None, - editor_menu_open: false, - editor_menu, terminal_open: HashSet::new(), terminal_heights: HashMap::new(), terminal: cx.new(|cx| TerminalView::new(terminal_actions, cx)), terminal_attached: None, + card, main_width: Cell::new(0.), actions, } @@ -362,6 +355,11 @@ impl MainView { self.set_diff(cwd, diff); return cx.notify(); } + if let Status::Call { token, event } = status { + self.card + .update(cx, |card, cx| card.on_call(token, event, cx)); + return; + } if matches!(status, Status::Connecting) { self.branches_requested.clear(); self.vcs.clear(); @@ -539,7 +537,7 @@ impl MainView { if !self.details_closed.remove(&key) { self.details_closed.insert(key); } - self.editor_menu_open = false; + self.card.update(cx, |card, _| card.close_popovers()); self.sync_vcs(); cx.notify(); } @@ -602,7 +600,6 @@ impl MainView { if !opened { self.terminal_open.remove(&key); } - self.editor_menu_open = false; self.sync_terminal(cx); if opened { let handle = self.terminal.focus_handle(cx); @@ -651,32 +648,10 @@ impl MainView { }) } - fn preferred_editor(&self) -> Option<&'static str> { - details::preferred_editor(self.editor_pick.as_deref(), &self.installed_editors()) - } - - fn open_in_editor(&mut self, editor: &'static str, cx: &mut Context) { - let Some(cwd) = self.details_cwd() else { - return; - }; - self.editor_pick = Some(editor.to_owned()); - let action = Action::OpenInEditor { - cwd, - editor: editor.to_owned(), - }; - if self.actions.try_send(action).is_err() { - eprintln!("[ui] could not ask to open the editor"); - } - cx.notify(); - } - /// The web's `editor.openFavorite`, live while the card's picker is. fn open_favorite_editor(&mut self, cx: &mut Context) { - if !self.details_open() { - return; - } - if let Some(editor) = self.preferred_editor() { - self.open_in_editor(editor, cx); + if self.details_open() { + self.card.update(cx, |card, cx| card.open_favorite(cx)); } } @@ -776,164 +751,51 @@ impl MainView { .on_click(cx.listener(|this, _, _, cx| this.toggle_details(cx))) } - /// The web's `ThreadDetailsCard`: Open in, then the branch and its changes. Environment, - /// scripts and git actions are not in this client yet. + /// The web's `ThreadDetailsCard`, fed the open thread's directory, status and limits. fn details_card(&self, available: f32, cx: &mut Context) -> Option { if !self.details_open() { return None; } let cwd = self.details_cwd()?; let max_height = details::card_height(self.main_width.get(), available)?; - let status = self.vcs.get(&cwd).and_then(Option::as_ref); - let editors = self.installed_editors(); - let has_run = self.selected_thread.as_deref().is_some_and(|id| { - self.shell() - .is_some_and(|shell| shell.rows.iter().any(|row| row.id == id && row.has_run)) + let shell = self.shell(); + let row = self + .selected_thread + .as_deref() + .and_then(|id| shell?.rows.iter().find(|row| row.id == id)); + let project = shell.and_then(|shell| match row { + Some(row) => shell + .project_entries + .iter() + .find(|entry| entry.contains(&row.project_id)), + None => active_project(self.selected_project.as_deref(), shell), }); - let inputs = Inputs { - has_cwd: true, - is_repo: status.is_some_and(|status| status.local.is_repo), - has_editors: !editors.is_empty(), - quiet_empty_draft: details::quiet_empty_draft(has_run, status), + let props = Props { + project_cwd: project.map_or_else(|| cwd.clone(), |entry| entry.root.clone()), + status: self.vcs.get(&cwd).and_then(Option::as_ref).cloned(), + editors: self.installed_editors(), + limits: row + .zip(self.providers.as_ref()) + .and_then(|(row, catalog)| catalog.usage_limits(&row.provider_instance)), + thread_id: row.map(|row| row.id.clone()), + project_id: row + .map(|row| row.project_id.clone()) + .or_else(|| project.map(|entry| entry.id.clone())), + has_run: row.is_some_and(|row| row.has_run), + max_height, + cwd, }; - let density = details::density_for(max_height, inputs); - let show = Show::resolve(density, inputs); - if show.is_empty() { - return None; - } - let theme = cx.theme(); - let (border, divider) = (gpui::white().opacity(0.05), theme.border); - let mut sections: Vec = Vec::new(); - if show.open_in { - sections.push(self.open_in_row(&editors, cx).into_any_element()); - } - if show.branch || show.changes { - let mut rows = v_flex(); - if let Some(status) = status.filter(|_| show.branch) { - let label = status - .local - .ref_name - .clone() - .unwrap_or_else(|| "Select ref".to_owned()); - rows = rows.child(detail_row(Icons::GitBranch, label, None, cx)); - } - if let Some(status) = status.filter(|_| show.changes) { - let totals = status.changes_totals(); - let counts = h_flex() - .gap_1() - .child(diff_count( - '+', - totals.insertions, - gpui::rgb(0x22c55e).into(), - cx, - )) - .child(diff_count( - '-', - totals.deletions, - gpui::rgb(0xef4444).into(), - cx, - )); - let changes = detail_row( - Icons::FileDiff, - "Changes".to_owned(), - Some(counts.into_any_element()), - cx, - ); - rows = rows.child( - div() - .id("details-changes") - .rounded(px(6.)) - .when(self.selected_thread.is_some(), |row| { - row.cursor_pointer() - .hover(|style| style.bg(gpui::white().opacity(0.06))) - .on_click(cx.listener(|this, _, _, cx| this.open_diff(cx))) - }) - .child(changes), - ); - } - sections.push(rows.into_any_element()); - } - let last = sections.len().saturating_sub(1); - let body = sections.into_iter().enumerate().map(|(index, section)| { - v_flex() - .pt_2() - .pb(px(10.)) - .px_2() - .when(density == Density::Full && index < last, |wrap| { - wrap.border_b_1().border_color(divider) - }) - .child(section) - }); + self.card.update(cx, |card, _| card.sync(props)); Some( div() - .id("thread-details-card") .absolute() .top(px(details::CARD_GAP)) .right(px(details::CARD_GAP)) - .w(px(details::CARD_WIDTH)) - .max_h(px(max_height)) - .overflow_y_scroll() - .rounded(px(16.)) - .border_1() - .border_color(border) - .bg(cx.theme().secondary.opacity(0.8)) - .shadow_lg() - .children(body) + .child(self.card.clone()) .into_any_element(), ) } - /// The web's `OpenInPicker`: the preferred editor, and a menu of the installed ones. - fn open_in_row(&self, editors: &[&'static str], cx: &mut Context) -> impl IntoElement { - let preferred = self.preferred_editor(); - let label = preferred.map_or("Open in", |id| details::editor_label(id)); - let (foreground, hover) = (cx.theme().foreground, cx.theme().list_hover); - let view = cx.entity(); - let menu = self.editor_menu.clone(); - menu.update(cx, |menu, _| { - menu.editors = editors.to_vec(); - menu.preferred = preferred; - }); - let content_menu = menu.clone(); - let popover = Popover::new("editor-menu") - .anchor(Anchor::TopRight) - .open(self.editor_menu_open) - .p_0() - .on_open_change(move |open, _, cx| { - view.update(cx, |this, cx| { - this.editor_menu_open = *open; - cx.notify(); - }); - }) - .trigger(EditorTrigger { open: false }) - .content(move |_, _, _| content_menu.clone()); - h_flex() - .gap_1() - .child( - h_flex() - .id("open-in-editor") - .flex_1() - .min_w_0() - .h_8() - .px_2() - .gap_2() - .rounded(px(6.)) - .cursor_pointer() - .text_sm() - .font_weight(FontWeight::MEDIUM) - .text_color(foreground) - .hover(move |style| style.bg(hover)) - .when(preferred.is_none(), |row| row.opacity(0.5)) - .child(Icon::new(Icons::SquareArrowOutUpRight).size_4()) - .child(div().truncate().child(match preferred { - Some(_) => format!("Open in {label}"), - None => label.to_owned(), - })) - .on_click(cx.listener(|this, _, _, cx| this.open_favorite_editor(cx))), - ) - .child(popover) - } - /// The web's `SidebarChrome`: toggle, then the brand and history while the sidebar is shown. fn sidebar_chrome(&self, open: bool, width: f32, cx: &mut Context) -> impl IntoElement { let backdrop = open && STAGE_BACKDROP; @@ -2365,129 +2227,6 @@ impl RenderOnce for PermissionsTrigger { } } -/// The details card's chevron, as a popover trigger. -#[derive(IntoElement)] -struct EditorTrigger { - open: bool, -} - -impl Selectable for EditorTrigger { - fn selected(self, _: bool) -> Self { - self - } - - fn is_selected(&self) -> bool { - false - } - - fn open(mut self, open: bool) -> Self { - self.open = open; - self - } - - fn is_open(&self) -> bool { - self.open - } -} - -impl RenderOnce for EditorTrigger { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let foreground = cx.theme().foreground; - ghost_icon_button( - "editor-menu-trigger", - Icon::new(Icons::ChevronDown).size_4(), - "Choose editor".into(), - true, - cx, - ) - .when(self.open, move |button| { - button.bg(foreground.opacity(0.06)).text_color(foreground) - }) - } -} - -/// The installed editors; clicking one only makes it the preferred one, as on the web. -struct EditorMenu { - view: gpui::WeakEntity, - editors: Vec<&'static str>, - preferred: Option<&'static str>, -} - -impl Render for EditorMenu { - fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { - let theme = cx.theme(); - let (foreground, muted, hover) = (theme.foreground, theme.muted_foreground, theme.accent); - let preferred = self.preferred; - v_flex() - .w(px(220.)) - .p_1() - .children(self.editors.iter().map(|&id| { - h_flex() - .id(id) - .h_8() - .px_2() - .gap_2() - .rounded(px(6.)) - .cursor_pointer() - .text_sm() - .text_color(foreground) - .hover(move |style| style.bg(hover)) - .child(div().flex_1().truncate().child(details::editor_label(id))) - .when(preferred == Some(id), |row| { - row.child( - div() - .text_xs() - .text_color(muted) - .child(details::OPEN_FAVORITE_SHORTCUT), - ) - }) - .on_click(cx.listener(move |this, _, _, cx| { - this.view - .update(cx, |view, cx| { - view.editor_pick = Some(id.to_owned()); - view.editor_menu_open = false; - cx.notify(); - }) - .ok(); - cx.notify(); - })) - })) - } -} - -/// A `h-8` row of the card: icon, label, and a trailing value. -fn detail_row( - icon: Icons, - label: String, - trailing: Option, - cx: &App, -) -> impl IntoElement { - let theme = cx.theme(); - h_flex() - .h_8() - .px_2() - .gap_2() - .text_sm() - .font_weight(FontWeight::MEDIUM) - .text_color(theme.foreground) - .child(Icon::new(icon).size_4().text_color(theme.muted_foreground)) - .child(div().flex_1().min_w_0().truncate().child(label)) - .children(trailing) -} - -/// `+N` or `-N`, tinted while non-zero. -fn diff_count(sign: char, count: u64, tint: Hsla, cx: &App) -> impl IntoElement { - let color = if count == 0 { - cx.theme().muted_foreground - } else { - tint - }; - div() - .text_xs() - .text_color(color) - .child(format!("{sign}{}", details::format_diff_count(count))) -} - /// The web's `ComposerPermissionsSection`: four icon segments, and the shown mode named below. struct PermissionsPanel { view: gpui::WeakEntity, From 61b2134a080ef6c0ae53ba173f0042994498446a Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 13:40:21 -0500 Subject: [PATCH 35/82] fix(desktop-gpui): composer surface uses the web's raised surface and outline --- apps/desktop-gpui/src/ui.rs | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 31d0270589dc..6d4f26e2a6e9 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -2095,10 +2095,8 @@ impl MainView { .p_px() .rounded(px(16.)) .border_1() - // The web's dark `--chat-composer-outline`. - .border_color(gpui::white().opacity(0.05)) - // The web's glass surface at `--glass-opacity`; GPUI has no backdrop blur. - .bg(cx.theme().secondary.opacity(0.8)) + .border_color(composer_outline(cx.theme().is_dark())) + .bg(composer_surface(cx.theme().is_dark())) .child( // The web's `px-4 pt-4 pb-2` body; the negative margin cancels the textarea's own padding. div().px_4().pt_4().pb_2().child( @@ -3538,6 +3536,26 @@ impl Render for MainView { } } +/// The web's `--chat-composer-outline`: white at 5% in dark, black at 8% in light. +fn composer_outline(dark: bool) -> Hsla { + if dark { + gpui::white().opacity(0.05) + } else { + gpui::black().opacity(0.08) + } +} + +/// The web's `--chat-composer-glass-surface` (`--surface-raised` in dark, neutral-950 mixed 97% with +/// white; `--card` in light), painted opaque as the web does where backdrop blur is unsupported, +/// since GPUI has no per-element backdrop blur and 80% over the timeline leaves text legible. +fn composer_surface(dark: bool) -> Hsla { + if dark { + gpui::rgb(0x111111).into() + } else { + gpui::white() + } +} + #[cfg(test)] mod tests { use super::*; From 283f541b51086773505dfb2e29885205dd149663 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 14:20:32 -0500 Subject: [PATCH 36/82] feat(desktop-gpui): terminal toolbar, split groups and multiple terminals per thread --- apps/desktop-gpui/src/assets.rs | 16 +- apps/desktop-gpui/src/connection.rs | 120 +++-- apps/desktop-gpui/src/main.rs | 6 + apps/desktop-gpui/src/status.rs | 9 +- apps/desktop-gpui/src/terminal.rs | 32 +- apps/desktop-gpui/src/terminal_state.rs | 309 +++++++++++++ apps/desktop-gpui/src/terminal_view.rs | 56 ++- apps/desktop-gpui/src/ui.rs | 567 ++++++++++++++++++++++-- 8 files changed, 1001 insertions(+), 114 deletions(-) create mode 100644 apps/desktop-gpui/src/terminal_state.rs diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 19df42b9239e..86e38561a4f4 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -50,7 +50,14 @@ gpui_kit_assets::icon_assets!( GitBranchPlus, GitCommitHorizontal, CloudDownload, - Info + Info, + SquareSplitHorizontal, + SquareSplitVertical, + Plus, + Trash, + Square, + SquareTerminal, + X ] ); @@ -240,6 +247,13 @@ mod tests { IconName::Paperclip, IconName::SquareMenu, IconName::PanelBottom, + IconName::SquareSplitHorizontal, + IconName::SquareSplitVertical, + IconName::Plus, + IconName::Trash, + IconName::Square, + IconName::SquareTerminal, + IconName::X, IconName::ChevronDown, IconName::SquareArrowOutUpRight, IconName::FileDiff, diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index aedd12c777f5..d3ca583d41b6 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -67,19 +67,22 @@ pub enum Action { cwd: String, editor: String, }, - /// The terminal the drawer shows; `None` detaches, and the shell keeps running. - AttachTerminal(Option), + /// The terminals the drawer shows; any not listed detach, and their shells keep running. + AttachTerminals(Vec), TerminalWrite { thread_id: String, + terminal_id: String, data: String, }, TerminalResize { thread_id: String, + terminal_id: String, grid: Grid, }, - /// Ends the thread's shell. + /// Ends a shell and its history. TerminalClose { thread_id: String, + terminal_id: String, }, /// A request the details card makes; the outcome comes back as `Status::Call` with `token`. Call { @@ -114,7 +117,7 @@ impl Action { Self::WatchVcs(_) => "watch the version control status", Self::LoadDiff { .. } => "load the diff", Self::OpenInEditor { .. } => "open in the editor", - Self::AttachTerminal(_) => "attach the terminal", + Self::AttachTerminals(_) => "attach the terminals", Self::TerminalWrite { .. } => "write to the terminal", Self::TerminalResize { .. } => "resize the terminal", Self::TerminalClose { .. } => "close the terminal", @@ -254,7 +257,7 @@ pub async fn run( // Outlives a socket: a reconnect subscribes to the thread again. let mut selected: Option = None; let mut watched: Option = None; - let mut terminal: Option = None; + let mut terminal: Vec = Vec::new(); let mut backoff = Backoff::new(); loop { publish(status, Status::Connecting); @@ -347,7 +350,7 @@ async fn serve( (selected, watched, wanted_terminal): ( &mut Option, &mut Option, - &mut Option, + &mut Vec, ), status: &StatusSender, ) -> String { @@ -376,7 +379,7 @@ async fn serve( match action { Action::SelectThread(id) => *selected = id, Action::WatchVcs(cwd) => *watched = cwd, - Action::AttachTerminal(target) => *wanted_terminal = target, + Action::AttachTerminals(targets) => *wanted_terminal = targets, _ => {} } } @@ -396,7 +399,7 @@ async fn serve( } let mut terminals = Terminals::default(); if let Err(error) = terminals - .select(wanted_terminal.as_ref(), &mut session, &mut sink) + .select(wanted_terminal, &mut session, &mut sink) .await { return error; @@ -456,24 +459,43 @@ async fn serve( } continue; } - Action::AttachTerminal(target) => { - *wanted_terminal = target.clone(); + Action::AttachTerminals(targets) => { + *wanted_terminal = targets.clone(); if let Err(error) = terminals - .select(target.as_ref(), &mut session, &mut sink) + .select(targets, &mut session, &mut sink) .await { return error; } continue; } - Action::TerminalWrite { thread_id, data } => session - .request::(&terminal::write_payload(thread_id, data)) + Action::TerminalWrite { + thread_id, + terminal_id, + data, + } => session + .request::(&terminal::write_payload( + thread_id, + terminal_id, + data, + )) .map(|(id, frame)| (id, frame, None)), - Action::TerminalResize { thread_id, grid } => session - .request::(&terminal::resize_payload(thread_id, *grid)) + Action::TerminalResize { + thread_id, + terminal_id, + grid, + } => session + .request::(&terminal::resize_payload( + thread_id, + terminal_id, + *grid, + )) .map(|(id, frame)| (id, frame, None)), - Action::TerminalClose { thread_id } => session - .request::(&terminal::close_payload(thread_id)) + Action::TerminalClose { + thread_id, + terminal_id, + } => session + .request::(&terminal::close_payload(thread_id, terminal_id)) .map(|(id, frame)| (id, frame, None)), Action::LoadDiff { cwd } => { match session.request::(&diff_query(cwd)) { @@ -769,10 +791,10 @@ impl VcsWatch { } } -/// The open terminal attachment, and the interrupted ones whose last frames still arrive. +/// The open terminal attachments, and the interrupted ones whose last frames still arrive. #[derive(Default)] struct Terminals { - open: Option, + open: Vec, stale: HashSet, } @@ -782,38 +804,43 @@ struct OpenTerminal { } impl Terminals { - /// Switches the attachment to `wanted`, interrupting the previous one. Detaching leaves the - /// shell running on the server; attaching to a target again replays its history. + /// Attaches exactly `wanted`, interrupting the rest. Detaching leaves a shell running on the + /// server; attaching to a target again replays its history. async fn select( &mut self, - wanted: Option<&Target>, + wanted: &[Target], session: &mut Session, sink: &mut Sink, ) -> Result<(), String> { - if self.open.as_ref().map(|open| &open.target) == wanted { - return Ok(()); - } - if let Some(open) = self.open.take() { + let (keep, dropped): (Vec<_>, Vec<_>) = std::mem::take(&mut self.open) + .into_iter() + .partition(|open| wanted.contains(&open.target)); + self.open = keep; + for open in dropped { let frame = session.interrupt(&open.request_id); self.stale.insert(open.request_id); sink.send(Message::text(frame)) .await .map_err(|error| error.to_string())?; } - let Some(target) = wanted else { - return Ok(()); - }; - let (request_id, frame) = session.request::(&target.attach_payload())?; - self.open = Some(OpenTerminal { - target: target.clone(), - request_id, - }); - sink.send(Message::text(frame)) - .await - .map_err(|error| error.to_string()) + for target in wanted { + if self.open.iter().any(|open| &open.target == target) { + continue; + } + let (request_id, frame) = + session.request::(&target.attach_payload())?; + self.open.push(OpenTerminal { + target: target.clone(), + request_id, + }); + sink.send(Message::text(frame)) + .await + .map_err(|error| error.to_string())?; + } + Ok(()) } - /// Consumes the events of the attach request, publishing each; hands back the rest. + /// Consumes the events of the attach requests, publishing each; hands back the rest. fn route(&mut self, event: Event, status: &StatusSender) -> Option { let id = match &event { Event::Item { request_id, .. } @@ -827,22 +854,29 @@ impl Terminals { } return None; } - let Some(open) = self.open.as_ref().filter(|open| open.request_id == id) else { + let Some(index) = self.open.iter().position(|open| open.request_id == id) else { return Some(event); }; - let thread_id = open.target.thread_id.clone(); + let target = self.open[index].target.clone(); let event = match event { Event::Item { value, .. } => terminal::decode_event(&value)?, Event::Failed { message, .. } => { - self.open = None; + self.open.remove(index); terminal::Event::Error(message) } _ => { - self.open = None; + self.open.remove(index); terminal::Event::Closed } }; - publish(status, Status::Terminal { thread_id, event }); + publish( + status, + Status::Terminal { + thread_id: target.thread_id, + terminal_id: target.terminal_id, + event, + }, + ); None } } diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index f1a6c354c79f..effb105b53e0 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -21,6 +21,7 @@ mod status; mod strip; mod terminal; mod terminal_screen; +mod terminal_state; mod terminal_view; mod thread; mod thread_card; @@ -111,6 +112,11 @@ fn main() { KeyBinding::new("cmd-o", ui::OpenFavoriteEditor, None), KeyBinding::new("cmd-[", ui::GoBack, None), KeyBinding::new("cmd-]", ui::GoForward, None), + // The web's `terminal.*` defaults, which apply while the terminal has focus. + KeyBinding::new("cmd-d", ui::SplitTerminal, Some("Terminal")), + KeyBinding::new("shift-cmd-d", ui::SplitTerminalVertical, Some("Terminal")), + KeyBinding::new("cmd-n", ui::NewTerminal, Some("Terminal")), + KeyBinding::new("cmd-w", ui::CloseTerminal, Some("Terminal")), ]); if let Some(supervisor) = supervisor { // The grace period outlasts gpui's quit timeout, so block here instead of awaiting. diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index 9f77c5b22678..9d8b2a951a9d 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -44,9 +44,10 @@ pub enum Status { cwd: String, status: Option, }, - /// One element of the terminal drawer's attach stream, for the thread it belongs to. + /// One element of a terminal's attach stream, for the thread and terminal it belongs to. Terminal { thread_id: String, + terminal_id: String, event: terminal::Event, }, /// The working-tree diff of a directory, or why it could not be loaded. @@ -214,7 +215,11 @@ impl fmt::Display for Status { Self::Thread { id, .. } => write!(f, "Thread {id}"), Self::Branch { root, .. } => write!(f, "Branch of {root}"), Self::Vcs { cwd, .. } => write!(f, "Status of {cwd}"), - Self::Terminal { thread_id, .. } => write!(f, "Terminal of {thread_id}"), + Self::Terminal { + thread_id, + terminal_id, + .. + } => write!(f, "Terminal {terminal_id} of {thread_id}"), Self::Diff { cwd, .. } => write!(f, "Diff of {cwd}"), Self::Call { token, .. } => write!(f, "Call {token}"), } diff --git a/apps/desktop-gpui/src/terminal.rs b/apps/desktop-gpui/src/terminal.rs index 37fd44ef6197..71cc31318c21 100644 --- a/apps/desktop-gpui/src/terminal.rs +++ b/apps/desktop-gpui/src/terminal.rs @@ -74,11 +74,20 @@ impl Grid { #[derive(Clone, Debug, PartialEq, Eq)] pub struct Target { pub thread_id: String, + pub terminal_id: String, pub project_root: String, pub worktree_path: Option, } impl Target { + /// The same place for another terminal of the thread. + pub fn for_terminal(&self, terminal_id: &str) -> Self { + Self { + terminal_id: terminal_id.to_owned(), + ..self.clone() + } + } + pub fn cwd(&self) -> &str { self.worktree_path.as_deref().unwrap_or(&self.project_root) } @@ -92,7 +101,7 @@ impl Target { } let mut payload = json!({ "threadId": self.thread_id, - "terminalId": DEFAULT_TERMINAL_ID, + "terminalId": self.terminal_id, "cwd": self.cwd(), "env": env, }); @@ -103,22 +112,22 @@ impl Target { } } -pub fn write_payload(thread_id: &str, data: &str) -> Value { - json!({ "threadId": thread_id, "terminalId": DEFAULT_TERMINAL_ID, "data": data }) +pub fn write_payload(thread_id: &str, terminal_id: &str, data: &str) -> Value { + json!({ "threadId": thread_id, "terminalId": terminal_id, "data": data }) } -pub fn resize_payload(thread_id: &str, grid: Grid) -> Value { +pub fn resize_payload(thread_id: &str, terminal_id: &str, grid: Grid) -> Value { json!({ "threadId": thread_id, - "terminalId": DEFAULT_TERMINAL_ID, + "terminalId": terminal_id, "cols": grid.cols, "rows": grid.rows, }) } /// Ends the session and its history, as `closeTerminalSession` does. -pub fn close_payload(thread_id: &str) -> Value { - json!({ "threadId": thread_id, "terminalId": DEFAULT_TERMINAL_ID, "deleteHistory": true }) +pub fn close_payload(thread_id: &str, terminal_id: &str) -> Value { + json!({ "threadId": thread_id, "terminalId": terminal_id, "deleteHistory": true }) } /// One element of the `terminal.attach` stream (`TerminalAttachStreamEvent`). @@ -360,6 +369,7 @@ mod tests { fn attach_runs_in_the_worktree_else_the_project_root() { let mut target = Target { thread_id: "t1".into(), + terminal_id: "term-1".into(), project_root: "/work/app".into(), worktree_path: None, }; @@ -389,17 +399,17 @@ mod tests { } #[test] - fn unary_payloads_name_the_default_terminal() { + fn unary_payloads_name_their_terminal() { assert_eq!( - write_payload("t1", "ls\r"), + write_payload("t1", "term-1", "ls\r"), json!({"threadId": "t1", "terminalId": "term-1", "data": "ls\r"}) ); assert_eq!( - resize_payload("t1", Grid { cols: 90, rows: 12 }), + resize_payload("t1", "term-1", Grid { cols: 90, rows: 12 }), json!({"threadId": "t1", "terminalId": "term-1", "cols": 90, "rows": 12}) ); assert_eq!( - close_payload("t1"), + close_payload("t1", "term-1"), json!({"threadId": "t1", "terminalId": "term-1", "deleteHistory": true}) ); } diff --git a/apps/desktop-gpui/src/terminal_state.rs b/apps/desktop-gpui/src/terminal_state.rs new file mode 100644 index 000000000000..d69780f8115c --- /dev/null +++ b/apps/desktop-gpui/src/terminal_state.rs @@ -0,0 +1,309 @@ +//! A thread's terminals and how they are grouped, ported from `terminalUiStateStore.ts` +//! (`upsertTerminalIntoGroups`, `closeThreadTerminal`), `packages/shared/src/terminalLabels.ts` +//! and `MAX_TERMINALS_PER_GROUP`. + +/// `MAX_TERMINALS_PER_GROUP`. +pub const MAX_PER_GROUP: usize = 4; + +/// A set of terminals shown together: side by side, or stacked when `vertical`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Group { + pub id: String, + pub ids: Vec, + pub vertical: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Placement { + /// A new terminal in its own group. + New, + /// A new terminal beside (or, when `vertical`, below) the active one. + Split { vertical: bool }, +} + +/// The terminals of one thread, in the order they were made. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Terminals { + pub groups: Vec, + pub active: String, + pub active_group: String, +} + +fn group_id(terminal_id: &str) -> String { + format!("group-{terminal_id}") +} + +impl Terminals { + pub fn is_empty(&self) -> bool { + self.groups.is_empty() + } + + pub fn ids(&self) -> impl Iterator { + self.groups + .iter() + .flat_map(|group| group.ids.iter().map(String::as_str)) + } + + pub fn count(&self) -> usize { + self.ids().count() + } + + /// The group the drawer shows: the active one, else the one holding the active terminal. + pub fn visible(&self) -> Option<&Group> { + self.groups + .iter() + .find(|group| group.id == self.active_group) + .or_else(|| { + self.groups + .iter() + .find(|group| group.ids.contains(&self.active)) + }) + } + + /// `hasReachedSplitLimit`. + pub fn split_limit_reached(&self) -> bool { + self.visible() + .is_some_and(|group| group.ids.len() >= MAX_PER_GROUP) + } + + /// Adds `id`; a split with no terminals is a new one, and a full group refuses it. + pub fn add(&mut self, id: &str, placement: Placement) -> bool { + if self.ids().any(|known| known == id) { + return false; + } + let placement = if self.is_empty() { + Placement::New + } else { + placement + }; + match placement { + Placement::New => { + let group = self.unique_group_id(id); + self.groups.push(Group { + id: group.clone(), + ids: vec![id.to_owned()], + vertical: false, + }); + self.active_group = group; + } + Placement::Split { vertical } => { + let Some(visible) = self.visible().map(|group| group.id.clone()) else { + return false; + }; + let active = self.active.clone(); + let Some(destination) = self.groups.iter_mut().find(|group| group.id == visible) + else { + return false; + }; + if destination.ids.len() >= MAX_PER_GROUP { + return false; + } + match destination.ids.iter().position(|known| *known == active) { + Some(anchor) => destination.ids.insert(anchor + 1, id.to_owned()), + None => destination.ids.push(id.to_owned()), + } + destination.vertical = vertical; + self.active_group = destination.id.clone(); + } + } + self.active = id.to_owned(); + true + } + + fn unique_group_id(&self, terminal_id: &str) -> String { + let base = group_id(terminal_id); + let mut candidate = base.clone(); + let mut index = 2; + while self.groups.iter().any(|group| group.id == candidate) { + candidate = format!("{base}-{index}"); + index += 1; + } + candidate + } + + pub fn activate(&mut self, id: &str) { + if let Some(group) = self + .groups + .iter() + .find(|group| group.ids.iter().any(|known| known == id)) + { + self.active_group = group.id.clone(); + self.active = id.to_owned(); + } + } + + /// `closeThreadTerminal`: when the active terminal goes, focus moves to its group neighbour + /// (next, else previous), else to whichever terminal now sits at its old position. + pub fn close(&mut self, id: &str) { + let all: Vec = self.ids().map(str::to_owned).collect(); + let Some(closed_index) = all.iter().position(|known| known == id) else { + return; + }; + let remaining: Vec<&String> = all.iter().filter(|known| *known != id).collect(); + if remaining.is_empty() { + *self = Self::default(); + return; + } + let next_active = if self.active == id { + let siblings = self + .groups + .iter() + .find(|group| group.ids.iter().any(|known| known == id)) + .map(|group| group.ids.clone()) + .unwrap_or_default(); + let at = siblings.iter().position(|known| known == id).unwrap_or(0); + siblings + .get(at + 1) + .or_else(|| at.checked_sub(1).and_then(|before| siblings.get(before))) + .cloned() + .unwrap_or_else(|| remaining[closed_index.min(remaining.len() - 1)].clone()) + } else { + self.active.clone() + }; + for group in &mut self.groups { + group.ids.retain(|known| known != id); + } + self.groups.retain(|group| !group.ids.is_empty()); + self.active_group = self + .groups + .iter() + .find(|group| group.ids.contains(&next_active)) + .or_else(|| self.groups.first()) + .map(|group| group.id.clone()) + .unwrap_or_default(); + self.active = next_active; + } + + /// `nextTerminalId`: the lowest free number, with a unique suffix so it cannot reuse a session + /// of the same name this client has not seen. + pub fn next_id(&self, suffix: &str) -> String { + let mut index = 1; + while self.ids().any(|known| number(known) == Some(index)) { + index += 1; + } + format!("term-{index}-{suffix}") + } +} + +/// The `N` of `term-N` or `terminal-N`, either with the uuid suffix `next_id` appends. +fn number(terminal_id: &str) -> Option { + let rest = terminal_id + .strip_prefix("terminal-") + .or_else(|| terminal_id.strip_prefix("term-"))?; + let (digits, suffix) = match rest.split_once('-') { + Some((digits, suffix)) => (digits, Some(suffix)), + None => (rest, None), + }; + if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + if suffix.is_some_and(|suffix| !is_uuid(suffix)) { + return None; + } + digits.parse().ok() +} + +fn is_uuid(text: &str) -> bool { + let parts: Vec<&str> = text.split('-').collect(); + parts.len() == 5 + && parts + .iter() + .zip([8, 4, 4, 4, 12]) + .all(|(part, len)| part.len() == len && part.bytes().all(|b| b.is_ascii_hexdigit())) +} + +/// `getTerminalLabel`. +pub fn label(terminal_id: &str) -> String { + match number(terminal_id) { + Some(number) => format!("Terminal {number}"), + None => terminal_id.to_owned(), + } +} + +/// A random uuid in its textual form, for `next_id`'s suffix. +pub fn uuid_suffix() -> String { + let mut bytes = [0u8; 16]; + let _ = getrandom::fill(&mut bytes); + let hex = hex::encode(bytes); + format!( + "{}-{}-{}-{}-{}", + &hex[0..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..32] + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + const UUID: &str = "0123abcd-0123-0123-0123-0123456789ab"; + + fn with(ids: &[&str]) -> Terminals { + let mut terminals = Terminals::default(); + for id in ids { + terminals.add(id, Placement::New); + } + terminals + } + + #[test] + fn a_split_joins_the_active_group_after_the_active_terminal() { + let mut terminals = with(&["term-1", "term-2"]); + terminals.activate("term-1"); + assert!(terminals.add("term-3", Placement::Split { vertical: true })); + let group = &terminals.groups[0]; + assert_eq!(group.ids, ["term-1", "term-3"]); + assert!(group.vertical); + assert_eq!(terminals.active, "term-3"); + assert_eq!(terminals.groups.len(), 2); + } + + #[test] + fn a_group_holds_at_most_four() { + let mut terminals = with(&["term-1"]); + for id in ["term-2", "term-3", "term-4"] { + assert!(terminals.add(id, Placement::Split { vertical: false })); + } + assert!(terminals.split_limit_reached()); + assert!(!terminals.add("term-5", Placement::Split { vertical: false })); + assert_eq!(terminals.count(), 4); + assert!(terminals.add("term-5", Placement::New)); + } + + #[test] + fn closing_the_active_terminal_moves_to_its_group_neighbour() { + let mut terminals = with(&["term-1"]); + terminals.add("term-2", Placement::Split { vertical: false }); + terminals.add("term-3", Placement::Split { vertical: false }); + terminals.activate("term-2"); + terminals.close("term-2"); + assert_eq!(terminals.active, "term-3"); + terminals.close("term-3"); + assert_eq!(terminals.active, "term-1"); + terminals.close("term-1"); + assert!(terminals.is_empty()); + } + + #[test] + fn closing_a_whole_group_falls_back_by_position() { + let mut terminals = with(&["term-1", "term-2", "term-3"]); + terminals.close("term-3"); + assert_eq!(terminals.active, "term-2"); + assert_eq!(terminals.active_group, "group-term-2"); + terminals.close("term-1"); + assert_eq!(terminals.active, "term-2"); + } + + #[test] + fn ids_take_the_lowest_free_number_and_label_without_the_suffix() { + let terminals = with(&["term-1", &format!("term-3-{UUID}")]); + assert_eq!(terminals.next_id("x"), "term-2-x"); + assert_eq!(label("term-1"), "Terminal 1"); + assert_eq!(label(&format!("term-3-{UUID}")), "Terminal 3"); + assert_eq!(label("build"), "build"); + assert!(is_uuid(&uuid_suffix())); + } +} diff --git a/apps/desktop-gpui/src/terminal_view.rs b/apps/desktop-gpui/src/terminal_view.rs index 6f6563cbe511..20bcb4e95fb9 100644 --- a/apps/desktop-gpui/src/terminal_view.rs +++ b/apps/desktop-gpui/src/terminal_view.rs @@ -21,7 +21,8 @@ const PADDING_Y: f32 = 8.; const LINE_HEIGHT_RATIO: f32 = 1.35; pub struct TerminalView { - thread_id: Option, + thread_id: String, + terminal_id: String, screen: Screen, /// Whether the server's snapshot has landed; input and resizes wait for it. ready: bool, @@ -38,9 +39,15 @@ pub struct TerminalView { } impl TerminalView { - pub fn new(actions: ActionSender, cx: &mut Context) -> Self { + pub fn new( + thread_id: String, + terminal_id: String, + actions: ActionSender, + cx: &mut Context, + ) -> Self { Self { - thread_id: None, + thread_id, + terminal_id, screen: Screen::new(FALLBACK_GRID), ready: false, ended: false, @@ -56,24 +63,8 @@ impl TerminalView { self.focus.focus(window, cx); } - /// Points the surface at a thread's terminal; the server replays the session's history. - pub fn show(&mut self, thread_id: Option) { - if self.thread_id == thread_id { - return; - } - self.thread_id = thread_id; - self.screen = Screen::new(FALLBACK_GRID); - self.ready = false; - self.ended = false; - self.requested = None; - self.scroll_remainder = 0.; - } - - /// Applies one element of the attach stream; an element for another thread is stale. - pub fn apply(&mut self, thread_id: &str, event: Event, cx: &mut Context) { - if self.thread_id.as_deref() != Some(thread_id) { - return; - } + /// Applies one element of the terminal's attach stream. + pub fn apply(&mut self, event: Event, cx: &mut Context) { match event { Event::Snapshot { history, @@ -110,7 +101,7 @@ impl TerminalView { cx.notify(); } - /// Prints why the shell is gone, and releases the server's session as the web does on exit. + /// Prints why the shell is gone; the drawer drops the terminal, as the web does on exit. fn end(&mut self, message: &str) { if self.ended { return; @@ -118,9 +109,6 @@ impl TerminalView { self.ended = true; self.screen .feed(&format!("\r\n\x1b[2m[{message}]\x1b[0m\r\n")); - if let Some(thread_id) = self.thread_id.clone() { - self.send(Action::TerminalClose { thread_id }); - } } fn send(&self, action: Action) { @@ -130,11 +118,15 @@ impl TerminalView { } fn write(&mut self, data: String) { - let Some(thread_id) = self.thread_id.clone().filter(|_| self.ready && !self.ended) else { + if !self.ready || self.ended { return; - }; + } self.screen.snap_to_bottom(); - self.send(Action::TerminalWrite { thread_id, data }); + self.send(Action::TerminalWrite { + thread_id: self.thread_id.clone(), + terminal_id: self.terminal_id.clone(), + data, + }); } /// What the shell's terminal queries asked to be written back. @@ -192,9 +184,11 @@ impl TerminalView { } self.requested = Some(grid); self.screen.resize(grid); - if let Some(thread_id) = self.thread_id.clone() { - self.send(Action::TerminalResize { thread_id, grid }); - } + self.send(Action::TerminalResize { + thread_id: self.thread_id.clone(), + terminal_id: self.terminal_id.clone(), + grid, + }); } } diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 6d4f26e2a6e9..17bad3743c4d 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -13,7 +13,8 @@ use crate::strip::{ self, Branch, Defaults, EnvMode, HOST_SHORTCUT, PERMISSIONS_SHORTCUT, RuntimeMode, WORKSPACE_SHORTCUT, }; -use crate::terminal::{self, Target}; +use crate::terminal::{self, Event as TerminalEvent, Target}; +use crate::terminal_state::{self, Placement, Terminals}; use crate::terminal_view::TerminalView; use crate::thread::{Entry, Kind}; use crate::thread_card::{self, ChainKind, PrLine}; @@ -21,8 +22,8 @@ use gpui::{ Anchor, Animation, AnimationExt as _, AnyElement, App, Bounds, Context, DragMoveEvent, Entity, Focusable as _, FollowMode, FontWeight, Hsla, IntoElement, Keystroke, ListAlignment, ListOffset, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, ObjectFit, ParentElement, - Pixels, Render, RenderOnce, Styled, Window, actions, canvas, div, ease_in_out, img, list, - prelude::*, px, rgb, + Pixels, Render, RenderOnce, SharedString, Styled, Window, actions, canvas, div, ease_in_out, + img, list, prelude::*, px, rgb, }; use gpui_component::{ ActiveTheme, Icon, IconName, Selectable, Sizable, TitleBar, @@ -42,6 +43,8 @@ use std::{ time::Duration, }; +/// The terminal sidebar's `w-36`. +const TERMINAL_SIDEBAR_WIDTH: f32 = 144.; /// The drag handle on the terminal drawer's top edge. const TERMINAL_HANDLE_HEIGHT: f32 = 8.; mod right_panel; @@ -73,6 +76,10 @@ actions!( GoForward, ToggleDetails, ToggleTerminal, + SplitTerminal, + SplitTerminalVertical, + NewTerminal, + CloseTerminal, ToggleRightPanel, OpenFavoriteEditor ] @@ -224,9 +231,12 @@ pub struct MainView { terminal_open: HashSet, /// The drawer's height per thread, as dragged. terminal_heights: HashMap, - terminal: Entity, - /// The terminal the connection was last asked to attach. - terminal_attached: Option, + /// The terminals of each thread with a drawer, in groups. + terminals: HashMap, + /// One surface per attached terminal, by thread and terminal id. + terminal_views: HashMap<(String, String), Entity>, + /// The terminals the connection was last asked to attach. + terminal_attached: Vec, card: Entity, /// The main area's width at the last render, for fitting the details card. main_width: Cell, @@ -267,7 +277,6 @@ impl MainView { selected: RuntimeMode::FullAccess, preview: None, }); - let terminal_actions = actions.clone(); let card = cx.new(|cx| DetailsCard::new(actions.clone(), window, cx)); cx.subscribe(&card, |this, _, _: &OpenDiff, cx| this.open_diff(cx)) .detach(); @@ -314,8 +323,9 @@ impl MainView { diff_list: cx.new(|_| right_panel::DiffList::new()), terminal_open: HashSet::new(), terminal_heights: HashMap::new(), - terminal: cx.new(|cx| TerminalView::new(terminal_actions, cx)), - terminal_attached: None, + terminals: HashMap::new(), + terminal_views: HashMap::new(), + terminal_attached: Vec::new(), card, main_width: Cell::new(0.), actions, @@ -346,9 +356,26 @@ impl MainView { self.vcs.insert(cwd, status); return cx.notify(); } - if let Status::Terminal { thread_id, event } = status { - self.terminal - .update(cx, |view, cx| view.apply(&thread_id, event, cx)); + if let Status::Terminal { + thread_id, + terminal_id, + event, + } = status + { + // The web closes a terminal whose session exited, without asking. + let exited = matches!( + event, + TerminalEvent::Exited | TerminalEvent::Snapshot { exited: true, .. } + ); + if let Some(view) = self + .terminal_views + .get(&(thread_id.clone(), terminal_id.clone())) + { + view.update(cx, |view, cx| view.apply(event, cx)); + } + if exited { + self.close_terminal(&thread_id, &terminal_id, cx); + } return; } if let Status::Diff { cwd, diff } = status { @@ -579,6 +606,7 @@ impl MainView { thread_id: key, project_root, worktree_path, + terminal_id: terminal::DEFAULT_TERMINAL_ID.into(), }) } @@ -588,7 +616,7 @@ impl MainView { && self.terminal_target().is_some() } - /// The web's `terminal.toggle`. Closing the drawer leaves the shell running. + /// The web's `terminal.toggle`. Closing the drawer leaves the shells running. fn toggle_terminal(&mut self, window: &mut Window, cx: &mut Context) { let Some(key) = self.details_key() else { return; @@ -597,36 +625,163 @@ impl MainView { return; } let opened = self.terminal_open.insert(key.clone()); - if !opened { + if opened { + let terminals = self.terminals.entry(key).or_default(); + if terminals.is_empty() { + terminals.add(terminal::DEFAULT_TERMINAL_ID, Placement::New); + } + } else { self.terminal_open.remove(&key); } self.sync_terminal(cx); if opened { - let handle = self.terminal.focus_handle(cx); - window.focus(&handle, cx); + self.focus_terminal(window, cx); } cx.notify(); } - /// Attaches the open drawer's terminal to the connection, and detaches when none shows. + fn active_terminal(&self) -> Option<(String, &Terminals)> { + let key = self.details_key()?; + let terminals = self.terminals.get(&key)?; + Some((key, terminals)) + } + + fn focus_terminal(&self, window: &mut Window, cx: &mut Context) { + let view = self + .active_terminal() + .and_then(|(key, terminals)| self.terminal_views.get(&(key, terminals.active.clone()))); + if let Some(view) = view { + let handle = view.focus_handle(cx); + window.focus(&handle, cx); + } + } + + /// The terminals the open drawer shows: the visible group. + fn wanted_terminals(&self) -> Vec { + if !self.terminal_visible() { + return Vec::new(); + } + let Some(base) = self.terminal_target() else { + return Vec::new(); + }; + self.terminals + .get(&base.thread_id) + .and_then(Terminals::visible) + .map(|group| group.ids.iter().map(|id| base.for_terminal(id)).collect()) + .unwrap_or_default() + } + + /// Attaches the visible group's terminals to the connection, and detaches the rest. fn sync_terminal(&mut self, cx: &mut Context) { - let wanted = self - .terminal_visible() - .then(|| self.terminal_target()) - .flatten(); + let wanted = self.wanted_terminals(); + for target in &wanted { + let key = (target.thread_id.clone(), target.terminal_id.clone()); + if !self.terminal_views.contains_key(&key) { + let actions = self.actions.clone(); + let (thread_id, terminal_id) = key.clone(); + let view = cx.new(|cx| TerminalView::new(thread_id, terminal_id, actions, cx)); + self.terminal_views.insert(key, view); + } + } + self.terminal_views.retain(|(thread_id, terminal_id), _| { + wanted + .iter() + .any(|target| &target.thread_id == thread_id && &target.terminal_id == terminal_id) + }); if wanted == self.terminal_attached { return; } - let thread_id = wanted.as_ref().map(|target| target.thread_id.clone()); - self.terminal.update(cx, |view, _| view.show(thread_id)); self.terminal_attached = wanted.clone(); if self .actions - .try_send(Action::AttachTerminal(wanted)) + .try_send(Action::AttachTerminals(wanted)) .is_err() { - eprintln!("[ui] could not ask for the terminal"); + eprintln!("[ui] could not ask for the terminals"); + } + } + + /// `onNewTerminal`, `onSplitTerminal` and `onSplitTerminalVertical`; a full group refuses. + fn add_terminal(&mut self, placement: Placement, window: &mut Window, cx: &mut Context) { + if !self.terminal_visible() { + return; + } + let Some(key) = self.details_key() else { + return; + }; + let terminals = self.terminals.entry(key).or_default(); + let id = terminals.next_id(&terminal_state::uuid_suffix()); + if !terminals.add(&id, placement) { + return; + } + self.sync_terminal(cx); + self.focus_terminal(window, cx); + cx.notify(); + } + + fn activate_terminal(&mut self, id: &str, window: &mut Window, cx: &mut Context) { + let Some(key) = self.details_key() else { + return; + }; + let Some(terminals) = self.terminals.get_mut(&key) else { + return; + }; + if terminals.active == id { + return; + } + terminals.activate(id); + self.sync_terminal(cx); + self.focus_terminal(window, cx); + cx.notify(); + } + + /// The web asks before closing a terminal by hand (`confirmTerminalClose`). + fn request_close(&mut self, id: String, window: &mut Window, cx: &mut Context) { + let Some(key) = self.details_key() else { + return; + }; + let label = terminal_state::label(&id); + let answer = window.prompt( + gpui::PromptLevel::Warning, + &format!("Close terminal \"{label}\"?"), + Some("This stops the running process and clears its history."), + &["Cancel", "Confirm"], + cx, + ); + cx.spawn_in(window, async move |this, cx| { + if answer.await == Ok(1) { + this.update_in(cx, |this, window, cx| { + this.close_terminal(&key, &id, cx); + this.focus_terminal(window, cx); + }) + .ok(); + } + }) + .detach(); + } + + /// `closeThreadTerminal`: ends the shell, and closes the drawer with the last terminal. + fn close_terminal(&mut self, thread_id: &str, terminal_id: &str, cx: &mut Context) { + let Some(terminals) = self.terminals.get_mut(thread_id) else { + return; + }; + if !terminals.ids().any(|id| id == terminal_id) { + return; + } + terminals.close(terminal_id); + if terminals.is_empty() { + self.terminals.remove(thread_id); + self.terminal_open.remove(thread_id); + } + let close = Action::TerminalClose { + thread_id: thread_id.to_owned(), + terminal_id: terminal_id.to_owned(), + }; + if self.actions.try_send(close).is_err() { + eprintln!("[ui] could not close the terminal"); } + self.sync_terminal(cx); + cx.notify(); } /// The drawer's height: as dragged, within what the window and chat column leave. @@ -1340,7 +1495,7 @@ impl MainView { .h(px(height)) .border_t_1() .border_color(line) - .child(self.terminal.clone()) + .child(self.terminal_body(cx)) .child( div() .id("terminal-resize") @@ -1371,6 +1526,351 @@ impl MainView { ) } + /// The drawer's panes, with the toolbar floating over them, or the sidebar beside them once + /// there is more than one terminal. + fn terminal_body(&self, cx: &mut Context) -> AnyElement { + let Some((key, terminals)) = self.active_terminal() else { + return div().into_any_element(); + }; + let theme = cx.theme(); + let (background, border) = (theme.background, theme.border); + let sidebar = terminals.count() > 1; + let panes = self.terminal_panes(&key, terminals, cx); + let buttons = self.terminal_buttons(terminals, sidebar, cx); + let (pill, side) = if sidebar { + (None, Some(buttons)) + } else { + (Some(buttons), None) + }; + div() + .relative() + .size_full() + .flex() + .bg(background) + .when(sidebar, |body| body.gap(px(6.))) + .child(div().flex_1().min_w_0().h_full().child(panes)) + .when_some(pill, |body, [split, split_vertical, new, close]| { + let line = border.opacity(0.8); + let separator = move || div().h(px(16.)).w(px(1.)).bg(line); + body.child( + h_flex() + .absolute() + .top(px(8.)) + .right(px(8.)) + .items_center() + .overflow_hidden() + .rounded(px(6.)) + .border_1() + .border_color(line) + .bg(background) + .shadow_xs() + .child(split) + .child(separator()) + .child(split_vertical) + .child(separator()) + .child(new) + .child(separator()) + .child(close), + ) + }) + .when_some(side, |body, [split, split_vertical, new, close]| { + let line = border.opacity(0.7); + let divided = + move |button: gpui::Stateful| button.border_l_1().border_color(line); + body.child( + v_flex() + .w(px(TERMINAL_SIDEBAR_WIDTH)) + .min_w(px(TERMINAL_SIDEBAR_WIDTH)) + .h_full() + .border_1() + .border_color(line) + .bg(cx.theme().muted.opacity(0.1)) + .child( + h_flex() + .h(px(22.)) + .items_stretch() + .justify_end() + .border_b_1() + .border_color(line) + .child(split) + .child(divided(split_vertical)) + .child(divided(new)) + .child(divided(close)), + ) + .child(self.terminal_list(terminals, cx)), + ) + }) + .into_any_element() + } + + /// The web's split, split vertically, new and close buttons, with its labels and shortcuts. + fn terminal_buttons( + &self, + terminals: &Terminals, + sidebar: bool, + cx: &mut Context, + ) -> [gpui::Stateful; 4] { + let limit = terminals.split_limit_reached(); + let active = terminals.active.clone(); + let accent = cx + .theme() + .foreground + .opacity(if sidebar { 0.042 } else { 0.06 }); + let split_label = |direction: &str, shortcut: &str| { + if limit { + format!( + "Split Terminal {direction} (max {} per group)", + terminal_state::MAX_PER_GROUP + ) + } else { + format!("Split Terminal {direction} ({shortcut})") + } + }; + let button = |id: &'static str, icon: Icons, label: String, disabled: bool, cx: &App| { + let foreground = cx.theme().foreground.opacity(0.9); + div() + .id(id) + .flex() + .items_center() + .justify_center() + .when(sidebar, |button| button.h_full().px(px(4.))) + .when(!sidebar, |button| button.p(px(4.))) + .text_color(foreground) + .child(Icon::new(icon).size(px(13.))) + .when(disabled, |button| button.opacity(0.64).cursor_not_allowed()) + .when(!disabled, |button| { + button.cursor_pointer().hover(move |style| style.bg(accent)) + }) + .tooltip(tooltip(label)) + }; + [ + button( + "terminal-split", + Icons::SquareSplitHorizontal, + split_label("Horizontally", "\u{2318}D"), + limit, + cx, + ) + .on_click(cx.listener(|this, _, window, cx| { + this.add_terminal(Placement::Split { vertical: false }, window, cx) + })), + button( + "terminal-split-vertical", + Icons::SquareSplitVertical, + split_label("Vertically", "\u{21e7}\u{2318}D"), + limit, + cx, + ) + .on_click(cx.listener(|this, _, window, cx| { + this.add_terminal(Placement::Split { vertical: true }, window, cx) + })), + button( + "terminal-new", + Icons::Plus, + "New Terminal (\u{2318}N)".into(), + false, + cx, + ) + .on_click( + cx.listener(|this, _, window, cx| this.add_terminal(Placement::New, window, cx)), + ), + button( + "terminal-close", + Icons::Trash, + "Close Terminal (\u{2318}W)".into(), + false, + cx, + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.request_close(active.clone(), window, cx) + })), + ] + } + + /// `TerminalSplitPanes`: the visible group's terminals side by side, or stacked. + fn terminal_panes( + &self, + key: &str, + terminals: &Terminals, + cx: &mut Context, + ) -> AnyElement { + let Some(group) = terminals.visible() else { + return div().into_any_element(); + }; + let border = cx.theme().border; + let vertical = group.vertical; + let panes = group + .ids + .iter() + .enumerate() + .filter_map(|(index, id)| { + let view = self.terminal_views.get(&(key.to_owned(), id.clone()))?; + let line = if *id == terminals.active { + border + } else { + border.opacity(0.7) + }; + let activate = id.clone(); + Some( + div() + .flex_1() + .min_w_0() + .min_h_0() + .when(index > 0 && vertical, |pane| pane.border_t_1()) + .when(index > 0 && !vertical, |pane| pane.border_l_1()) + .border_color(line) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, window, cx| { + this.activate_terminal(&activate, window, cx) + }), + ) + .child(view.clone()), + ) + }) + .collect::>(); + let container = if vertical { v_flex() } else { h_flex() }; + container + .size_full() + .overflow_hidden() + .children(panes) + .into_any_element() + } + + /// `TerminalSidebarGroup` for each group. + fn terminal_list(&self, terminals: &Terminals, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let (foreground, muted, muted_background) = + (theme.foreground, theme.muted_foreground, theme.muted); + let accent = |scale: f32| foreground.opacity(0.06 * scale); + let headers = + terminals.groups.len() > 1 || terminals.groups.iter().any(|group| group.ids.len() > 1); + let groups = terminals.groups.iter().map(|group| { + let active_group = group.ids.contains(&terminals.active); + let focus = if active_group { + terminals.active.clone() + } else { + group.ids.first().cloned().unwrap_or_default() + }; + let split = group.ids.len() > 1; + let (title, icon) = match (split, group.vertical) { + (false, _) => ("Single", Icons::Square), + (true, true) => ("Stacked", Icons::SquareSplitVertical), + (true, false) => ("Side by side", Icons::SquareSplitHorizontal), + }; + let header = div() + .id(SharedString::from(format!("terminal-group-{}", group.id))) + .flex() + .items_center() + .gap(px(4.)) + .h(px(22.)) + .w_full() + .px(px(6.)) + .rounded(px(4.)) + .text_size(px(11.)) + .cursor_pointer() + .when(active_group, |header| { + header.bg(accent(0.5)).text_color(foreground) + }) + .when(!active_group, |header| { + header + .text_color(muted) + .hover(move |style| style.bg(accent(0.4)).text_color(foreground)) + }) + .child(Icon::new(icon).size(px(12.)).flex_shrink_0()) + .child(div().flex_1().min_w_0().truncate().child(title)) + .child( + div() + .text_size(px(10.)) + .text_color(muted.opacity(0.7)) + .child(group.ids.len().to_string()), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.activate_terminal(&focus, window, cx) + })); + let rows = group.ids.iter().map(|id| { + let active = *id == terminals.active; + let label = terminal_state::label(id); + let close_label = if active { + format!("Close {label} (\u{2318}W)") + } else { + format!("Close {label}") + }; + let hover_group = SharedString::from(format!("terminal-tab-{id}")); + let (close_id, select_id, close_target, select_target) = + (id.clone(), id.clone(), id.clone(), id.clone()); + h_flex() + .id(SharedString::from(format!("terminal-row-{id}"))) + .group(hover_group.clone()) + .h(px(24.)) + .w_full() + .items_center() + .gap(px(2.)) + .rounded(px(6.)) + .pr(px(8.)) + .pl(px(6.)) + .text_xs() + .when(active, |row| row.bg(accent(1.)).text_color(foreground)) + .when(!active, |row| { + row.text_color(muted) + .hover(move |style| style.bg(accent(0.6)).text_color(foreground)) + }) + .child( + div() + .id(SharedString::from(format!("terminal-close-{close_id}"))) + .flex() + .flex_none() + .items_center() + .justify_center() + .size(px(16.)) + .rounded(px(2.)) + .cursor_pointer() + .hover(move |style| style.bg(muted_background)) + .tooltip(tooltip(close_label)) + .child( + div() + .flex() + .group_hover(hover_group.clone(), |style| style.hidden()) + .child(Icon::new(Icons::SquareTerminal).size(px(12.))), + ) + .child( + div() + .hidden() + .group_hover(hover_group, |style| style.flex()) + .child(Icon::new(Icons::X).size(px(12.))), + ) + .on_click(cx.listener(move |this, _, window, cx| { + this.request_close(close_target.clone(), window, cx) + })), + ) + .child( + div() + .id(SharedString::from(format!("terminal-select-{select_id}"))) + .flex_1() + .min_w_0() + .truncate() + .cursor_pointer() + .child(label) + .on_click(cx.listener(move |this, _, window, cx| { + this.activate_terminal(&select_target, window, cx) + })), + ) + }); + v_flex() + .pb(px(2.)) + .when(headers, |group| group.child(header)) + .child(v_flex().gap(px(2.)).children(rows)) + }); + v_flex() + .id("terminal-list") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .px(px(4.)) + .py(px(4.)) + .children(groups) + } + fn main_content(&self, viewport_height: f32, cx: &mut Context) -> AnyElement { let Some(shell) = self.shell() else { return centered(cx) @@ -3506,6 +4006,21 @@ impl Render for MainView { this.toggle_terminal(window, cx) }), ) + .on_action(cx.listener(|this, _: &SplitTerminal, window, cx| { + this.add_terminal(Placement::Split { vertical: false }, window, cx) + })) + .on_action(cx.listener(|this, _: &SplitTerminalVertical, window, cx| { + this.add_terminal(Placement::Split { vertical: true }, window, cx) + })) + .on_action(cx.listener(|this, _: &NewTerminal, window, cx| { + this.add_terminal(Placement::New, window, cx) + })) + .on_action(cx.listener(|this, _: &CloseTerminal, window, cx| { + let active = this.active_terminal().map(|(_, t)| t.active.clone()); + if let Some(active) = active { + this.request_close(active, window, cx) + } + })) .on_action( cx.listener(|this, _: &OpenFavoriteEditor, _, cx| this.open_favorite_editor(cx)), ) From d62959dd283293001e42e19efd3b87af3453f567 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 14:59:03 -0500 Subject: [PATCH 37/82] feat(desktop-gpui): toast widget with a host for add, update and dismiss by id --- apps/desktop-gpui/src/assets.rs | 5 + apps/desktop-gpui/src/details_card.rs | 4 +- apps/desktop-gpui/src/toast.rs | 1019 +++++++++++++++++++++++++ 3 files changed, 1027 insertions(+), 1 deletion(-) create mode 100644 apps/desktop-gpui/src/toast.rs diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 86e38561a4f4..a4f930388424 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -33,6 +33,11 @@ gpui_kit_assets::icon_assets!( CircleDashed, CircleDotDashed, CircleAlert, + ChevronUp, + CircleCheck, + TriangleAlert, + Copy, + LoaderCircle, Clock, Check, GitPullRequestArrow, diff --git a/apps/desktop-gpui/src/details_card.rs b/apps/desktop-gpui/src/details_card.rs index 142f82c94eeb..7a726e5d3a9b 100644 --- a/apps/desktop-gpui/src/details_card.rs +++ b/apps/desktop-gpui/src/details_card.rs @@ -589,7 +589,9 @@ fn now_ms() -> i64 { .map_or(0, |elapsed| elapsed.as_millis() as i64) } -fn tip(text: impl Into) -> impl Fn(&mut Window, &mut App) -> gpui::AnyView { +pub(crate) fn tip( + text: impl Into, +) -> impl Fn(&mut Window, &mut App) -> gpui::AnyView { let text = text.into(); move |window, cx| { Tooltip::new(text.clone()) diff --git a/apps/desktop-gpui/src/toast.rs b/apps/desktop-gpui/src/toast.rs new file mode 100644 index 000000000000..96a152d4589b --- /dev/null +++ b/apps/desktop-gpui/src/toast.rs @@ -0,0 +1,1019 @@ +//! The toast widget (`ui/toast.tsx`, `ui/toast.logic.ts`) on Base UI's toast store semantics. +//! +//! `ToastHost` is an entity the window renders last: it owns the stack, the timers and the +//! hover state, and draws the cards at the web's top-right corner. Everything else goes through +//! `add`, `update` and `dismiss` by `ToastId`. +//! +//! ```ignore +//! let id = host.update(cx, |host, cx| { +//! host.add( +//! Toast::new("Some requests are slow") +//! .kind(Kind::Warning) +//! .description("2 requests waiting longer than 15s.") +//! .timeout(Timeout::Never) +//! .collapse(Collapse::new("Show requests", "Hide requests").on_description()) +//! .body(|_, _| div().child("any element").into_any_element()), +//! cx, +//! ) +//! }); +//! host.update(cx, |host, cx| host.dismiss(id, cx)); +//! ``` + +// The widget is the web's whole surface; the first caller (slow requests) uses part of it. +#![allow(dead_code)] + +use crate::details_card::tip; +use gpui::{ + AnyElement, App, BoxShadow, ClipboardItem, Context, Div, FontWeight, Hsla, + InteractiveElement as _, IntoElement, ParentElement, Render, SharedString, + StatefulInteractiveElement as _, Styled, Subscription, Window, div, point, prelude::*, px, +}; +use gpui_component::{ + ActiveTheme, Icon, Sizable, Size, + button::{Button, ButtonVariants}, + h_flex, + spinner::Spinner, + v_flex, +}; +use gpui_kit_assets::IconName as Icons; +use std::{ + rc::Rc, + time::{Duration, Instant}, +}; + +/// Base UI's provider `timeout` default. +const DEFAULT_TIMEOUT: Duration = Duration::from_millis(5000); +/// Base UI's provider `limit` default: older toasts are hidden. +const LIMIT: usize = 3; +/// `max-w-90`. +const MAX_WIDTH: f32 = 360.; +/// `--toast-inset`: `--spacing(4)`, `--spacing(8)` from the `sm` breakpoint. +const INSET: f32 = 16.; +const INSET_WIDE: f32 = 32.; +const SM_BREAKPOINT: f32 = 640.; +/// `--toast-gap` between expanded cards and `--toast-peek` between collapsed ones. +const GAP: f32 = 12.; +const PEEK: f32 = 12.; +/// Each collapsed card behind the front one loses `.1` of scale. +const SCALE_STEP: f32 = 0.1; +/// `useCopyToClipboard`'s default. +const COPIED_FOR: Duration = Duration::from_millis(2000); +/// `ERROR_DESCRIPTION_CLAMP_MIN_CHARS`, clamped to `line-clamp-4`. +const ERROR_CLAMP_MIN_CHARS: usize = 180; +const ERROR_CLAMP_LINES: usize = 4; +/// `toastExpandablePanelClassName`'s `max-h-40`. +const PANEL_MAX_HEIGHT: f32 = 160.; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct ToastId(u64); + +/// The web's toast `type`, which picks the icon. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Success, + Error, + Warning, + Info, + Loading, +} + +/// The web's `timeout`: unset, `0`, or milliseconds. A loading toast never times out. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Timeout { + #[default] + Default, + Never, + After(Duration), +} + +impl Timeout { + fn resolve(self, kind: Option) -> Option { + if kind == Some(Kind::Loading) { + return None; + } + match self { + Self::Default => Some(DEFAULT_TIMEOUT), + Self::Never => None, + Self::After(duration) => (!duration.is_zero()).then_some(duration), + } + } +} + +/// The web's `actionVariant` and `secondaryActionVariant` defaults. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Variant { + #[default] + Primary, + Outline, +} + +type Callback = Rc; +type BodyBuilder = Rc AnyElement>; +type CloseCallback = Rc; + +#[derive(Clone)] +pub struct Action { + label: SharedString, + variant: Variant, + on_click: Callback, +} + +impl Action { + pub fn new( + label: impl Into, + on_click: impl Fn(&mut Window, &mut App) + 'static, + ) -> Self { + Self { + label: label.into(), + variant: Variant::default(), + on_click: Rc::new(on_click), + } + } + + pub fn variant(mut self, variant: Variant) -> Self { + self.variant = variant; + self + } +} + +/// The web's `expandableLabels` and `expandableDescriptionTrigger`. +#[derive(Clone)] +pub struct Collapse { + expand: SharedString, + collapse: SharedString, + on_description: bool, +} + +impl Collapse { + pub fn new(expand: impl Into, collapse: impl Into) -> Self { + Self { + expand: expand.into(), + collapse: collapse.into(), + on_description: false, + } + } + + /// Makes the description row the toggle, with the chevron at its end. + pub fn on_description(mut self) -> Self { + self.on_description = true; + self + } +} + +impl Default for Collapse { + fn default() -> Self { + Self::new("Show details", "Hide details") + } +} + +#[derive(Clone)] +pub struct Toast { + kind: Option, + title: SharedString, + description: Option, + actions: Vec, + stacked_actions: bool, + hide_copy: bool, + timeout: Timeout, + body: Option, + collapse: Option, + on_close: Option, +} + +impl Toast { + pub fn new(title: impl Into) -> Self { + Self { + kind: None, + title: title.into(), + description: None, + actions: Vec::new(), + stacked_actions: false, + hide_copy: false, + timeout: Timeout::default(), + body: None, + collapse: None, + on_close: None, + } + } + + pub fn kind(mut self, kind: Kind) -> Self { + self.kind = Some(kind); + self + } + + pub fn description(mut self, description: impl Into) -> Self { + self.description = Some(description.into()); + self + } + + pub fn action(mut self, action: Action) -> Self { + self.actions.push(action); + self + } + + /// The web's `actionLayout: "stacked-end"`: actions under the text, right-aligned. + pub fn stacked_actions(mut self) -> Self { + self.stacked_actions = true; + self + } + + /// The web's `hideCopyButton`; error toasts with a description otherwise offer to copy it. + pub fn hide_copy(mut self) -> Self { + self.hide_copy = true; + self + } + + pub fn timeout(mut self, timeout: Timeout) -> Self { + self.timeout = timeout; + self + } + + /// Any element under the description, built on each render so it can read live state. It + /// scrolls past `max-h-40`; with `collapse` it starts hidden. + pub fn body(mut self, body: impl Fn(&mut Window, &mut App) -> AnyElement + 'static) -> Self { + self.body = Some(Rc::new(body)); + self + } + + pub fn collapse(mut self, collapse: Collapse) -> Self { + self.collapse = Some(collapse); + self + } + + /// Runs after the toast leaves, whether the user, a timeout or `dismiss` closed it. + pub fn on_close(mut self, on_close: impl Fn(&mut App) + 'static) -> Self { + self.on_close = Some(Rc::new(on_close)); + self + } + + fn copy_text(&self) -> Option<&SharedString> { + self.description + .as_ref() + .filter(|_| self.kind == Some(Kind::Error) && !self.hide_copy) + } + + fn has_trailing_controls(&self) -> bool { + self.copy_text().is_some() || !self.actions.is_empty() + } +} + +/// Base UI's pausable timer: `remaining` counts down only while `started` is set. +struct Timer { + delay: Duration, + remaining: Duration, + started: Option, +} + +impl Timer { + fn elapsed(&self, now: Instant) -> Duration { + self.started.map_or(Duration::ZERO, |started| { + now.saturating_duration_since(started) + }) + } + + fn left(&self, now: Instant) -> Option { + self.started + .map(|_| self.remaining.saturating_sub(self.elapsed(now))) + } +} + +struct Entry { + id: ToastId, + toast: Toast, + timer: Option, + open: bool, + copied: bool, +} + +/// The toasts newest first, which is also the stacking order, and their timers. +#[derive(Default)] +struct Stack { + entries: Vec, + next_id: u64, + paused: bool, +} + +impl Stack { + fn timer(&self, toast: &Toast, now: Instant) -> Option { + let delay = toast.timeout.resolve(toast.kind)?; + Some(Timer { + delay, + remaining: delay, + started: (!self.paused).then_some(now), + }) + } + + fn add(&mut self, toast: Toast, now: Instant) -> ToastId { + let id = ToastId(self.next_id); + self.next_id += 1; + let timer = self.timer(&toast, now); + self.entries.insert( + 0, + Entry { + id, + toast, + timer, + open: false, + copied: false, + }, + ); + id + } + + /// Replaces the toast's content; the timer restarts only when its length changed. + fn update(&mut self, id: ToastId, toast: Toast, now: Instant) -> bool { + let Some(index) = self.entries.iter().position(|entry| entry.id == id) else { + return false; + }; + let delay = toast.timeout.resolve(toast.kind); + if self.entries[index].timer.as_ref().map(|timer| timer.delay) != delay { + self.entries[index].timer = self.timer(&toast, now); + } + self.entries[index].toast = toast; + true + } + + fn dismiss(&mut self, id: ToastId) -> Option { + let index = self.entries.iter().position(|entry| entry.id == id)?; + Some(self.entries.remove(index).toast) + } + + fn set_paused(&mut self, paused: bool, now: Instant) { + if self.paused == paused { + return; + } + self.paused = paused; + for timer in self + .entries + .iter_mut() + .filter_map(|entry| entry.timer.as_mut()) + { + if paused { + if let Some(left) = timer.left(now) { + timer.remaining = left; + timer.started = None; + } + } else { + if timer.remaining.is_zero() { + timer.remaining = timer.delay; + } + timer.started = Some(now); + } + } + } + + /// How long until the next running timer fires. + fn next_expiry(&self, now: Instant) -> Option { + self.entries + .iter() + .filter_map(|entry| entry.timer.as_ref()?.left(now)) + .min() + } + + fn expired(&self, now: Instant) -> Vec { + self.entries + .iter() + .filter(|entry| { + entry + .timer + .as_ref() + .and_then(|timer| timer.left(now)) + .is_some_and(|left| left.is_zero()) + }) + .map(|entry| entry.id) + .collect() + } + + fn entry_mut(&mut self, id: ToastId) -> Option<&mut Entry> { + self.entries.iter_mut().find(|entry| entry.id == id) + } +} + +/// The scale of the card `index` places behind the front one while the stack is collapsed. +fn collapsed_scale(index: usize) -> f32 { + (1. - index as f32 * SCALE_STEP).max(0.) +} + +/// The web's `--toast-inset` for a window width. +fn inset(window_width: f32) -> f32 { + if window_width >= SM_BREAKPOINT { + INSET_WIDE + } else { + INSET + } +} + +/// `w-[calc(100%-var(--toast-inset)*2)] max-w-90`. +fn toast_width(window_width: f32) -> f32 { + (window_width - inset(window_width) * 2.).clamp(0., MAX_WIDTH) +} + +fn clamp_lines(kind: Option, description: &str) -> Option { + (kind == Some(Kind::Error) && description.chars().count() >= ERROR_CLAMP_MIN_CHARS) + .then_some(ERROR_CLAMP_LINES) +} + +/// Renders the stack at the window's top-right corner, below the top bar. +pub struct ToastHost { + stack: Stack, + top_offset: f32, + hovered: bool, + window_active: bool, + /// Bumped on every reschedule so a stale timer task does nothing. + epoch: u64, + _activation: Subscription, +} + +impl ToastHost { + /// `top_offset` is the web's `--toast-header-offset`, the top bar's height. + pub fn new(top_offset: f32, window: &mut Window, cx: &mut Context) -> Self { + let _activation = cx.observe_window_activation(window, |this, window, cx| { + this.window_active = window.is_window_active(); + this.sync_pause(cx); + }); + Self { + stack: Stack::default(), + top_offset, + hovered: false, + window_active: window.is_window_active(), + epoch: 0, + _activation, + } + } + + pub fn add(&mut self, toast: Toast, cx: &mut Context) -> ToastId { + let id = self.stack.add(toast, Instant::now()); + self.sync_pause(cx); + id + } + + /// Replaces a toast's content; does nothing once it is gone, as the web's `update` does. + pub fn update(&mut self, id: ToastId, toast: Toast, cx: &mut Context) { + if self.stack.update(id, toast, Instant::now()) { + self.sync_pause(cx); + } + } + + pub fn dismiss(&mut self, id: ToastId, cx: &mut Context) { + let Some(toast) = self.stack.dismiss(id) else { + return; + }; + if let Some(on_close) = toast.on_close { + // The callback may reach back into this entity, which is borrowed right now. + cx.defer(move |cx| on_close(cx)); + } + self.reschedule(cx); + } + + /// Timers stand still while the stack is hovered (Base UI's `expanded`) or the window is not + /// the active one. + fn sync_pause(&mut self, cx: &mut Context) { + self.stack + .set_paused(self.hovered || !self.window_active, Instant::now()); + self.reschedule(cx); + } + + fn reschedule(&mut self, cx: &mut Context) { + self.epoch += 1; + let epoch = self.epoch; + cx.notify(); + let Some(wait) = self.stack.next_expiry(Instant::now()) else { + return; + }; + cx.spawn(async move |this, cx| { + cx.background_executor().timer(wait).await; + this.update(cx, |this, cx| { + if this.epoch != epoch { + return; + } + for id in this.stack.expired(Instant::now()) { + this.dismiss(id, cx); + } + this.reschedule(cx); + }) + .ok(); + }) + .detach(); + } + + fn copy(&mut self, id: ToastId, text: SharedString, cx: &mut Context) { + cx.write_to_clipboard(ClipboardItem::new_string(text.to_string())); + if let Some(entry) = self.stack.entry_mut(id) { + entry.copied = true; + } + cx.spawn(async move |this, cx| { + cx.background_executor().timer(COPIED_FOR).await; + this.update(cx, |this, cx| { + if let Some(entry) = this.stack.entry_mut(id) { + entry.copied = false; + cx.notify(); + } + }) + .ok(); + }) + .detach(); + cx.notify(); + } + + fn toggle(&mut self, id: ToastId, cx: &mut Context) { + if let Some(entry) = self.stack.entry_mut(id) { + entry.open = !entry.open; + cx.notify(); + } + } +} + +struct Palette { + dark: bool, + foreground: Hsla, + muted: Hsla, + popover: Hsla, + /// `border-border/60`. + border: Hsla, + /// `dropdown-glass`'s `border-contrast-foreground/10`. + glass_border: Hsla, +} + +impl Palette { + fn new(cx: &App) -> Self { + let theme = cx.theme(); + let dark = theme.is_dark(); + Self { + dark, + foreground: theme.foreground, + muted: theme.muted_foreground, + popover: theme.popover, + border: theme.border.opacity(0.6), + glass_border: if dark { gpui::white() } else { gpui::black() }.opacity(0.1), + } + } + + /// `text-destructive`, `text-info`, `text-success` and `text-warning`. + fn kind(&self, kind: Kind) -> Hsla { + match kind { + Kind::Error if self.dark => gpui::rgb(0xf15757), + Kind::Error => gpui::rgb(0xef4444), + Kind::Info => gpui::rgb(0x3b82f6), + Kind::Success => gpui::rgb(0x10b981), + Kind::Warning => gpui::rgb(0xf59e0b), + Kind::Loading => return self.foreground.opacity(0.8), + } + .into() + } +} + +/// `shadow-xl shadow-black/25`. +fn shadow() -> Vec { + let color = gpui::black().opacity(0.25); + vec![ + BoxShadow { + color, + offset: point(px(0.), px(20.)), + blur_radius: px(25.), + spread_radius: px(-5.), + inset: false, + }, + BoxShadow { + color, + offset: point(px(0.), px(8.)), + blur_radius: px(10.), + spread_radius: px(-6.), + inset: false, + }, + ] +} + +/// The card's own box: `dropdown-glass rounded-lg`, painted opaque because GPUI has no backdrop +/// blur to make the glass's 84% fill legible over the content. +fn surface(palette: &Palette) -> Div { + div() + .rounded(px(8.)) + .border_1() + .border_color(palette.glass_border) + .bg(palette.popover) +} + +fn type_icon(kind: Kind, palette: &Palette) -> AnyElement { + let color = palette.kind(kind); + let glyph: AnyElement = match kind { + Kind::Loading => Spinner::new() + .icon(Icon::new(Icons::LoaderCircle)) + .color(color) + .with_size(Size::Size(px(16.))) + .into_any_element(), + _ => Icon::new(match kind { + Kind::Error => Icons::CircleAlert, + Kind::Info => Icons::Info, + Kind::Success => Icons::CircleCheck, + _ => Icons::TriangleAlert, + }) + .size(px(16.)) + .text_color(color) + .into_any_element(), + }; + // `[&>svg]:h-lh [&>svg]:w-4`: a 16px glyph centered in the 20px line. + div() + .flex() + .items_center() + .justify_center() + .flex_shrink_0() + .w(px(16.)) + .h(px(20.)) + .child(glyph) + .into_any_element() +} + +fn chevron(open: bool, palette: &Palette) -> Icon { + Icon::new(if open { + Icons::ChevronUp + } else { + Icons::ChevronDown + }) + .size(px(14.)) + .flex_shrink_0() + .text_color(palette.muted.opacity(0.8)) +} + +impl ToastHost { + fn card( + &self, + entry: &Entry, + palette: &Palette, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let id = entry.id; + let toast = &entry.toast; + let stacked = toast.stacked_actions && !toast.actions.is_empty(); + let trailing = toast.has_trailing_controls(); + + let title = h_flex() + .min_w_0() + .gap(px(8.)) + .when(!stacked && !trailing, |row| row.pr(px(24.))) + .children(toast.kind.map(|kind| type_icon(kind, palette))) + .child( + div() + .min_w_0() + .font_weight(FontWeight::MEDIUM) + .child(toast.title.clone()), + ); + + let description = |palette: &Palette| { + toast.description.as_ref().map(|text| { + div() + .min_w_0() + .text_color(palette.muted) + .when_some(clamp_lines(toast.kind, text), |div, lines| { + div.line_clamp(lines) + }) + .child(text.clone()) + }) + }; + + let body = toast.body.as_ref().map(|build| build(window, cx)); + let panel = |body: AnyElement, scrolls: bool| { + div() + .id(("toast-body", id.0)) + .mt(px(8.)) + .max_h(px(PANEL_MAX_HEIGHT)) + .min_h_0() + .pr(px(2.)) + .overflow_y_scroll() + .when(!scrolls, |panel| panel.mt_0()) + .child(body) + }; + + let mut text = v_flex() + .min_w_0() + .gap(px(2.)) + .when(stacked, |column| column.pr(px(20.))) + .when(!stacked, |column| column.flex_1()) + .child(title); + match (&toast.collapse, body) { + (None, body) => { + text = text + .children(description(palette)) + .children(body.map(|body| panel(body, true))); + } + (Some(_), None) => text = text.children(description(palette)), + (Some(collapse), Some(body)) => { + let label = if entry.open { + collapse.collapse.clone() + } else { + collapse.expand.clone() + }; + let toggle = cx.listener(move |this, _, _, cx| this.toggle(id, cx)); + if collapse.on_description { + text = text.child( + h_flex() + .id(("toast-toggle", id.0)) + .w_full() + .min_w_0() + .items_start() + .gap(px(6.)) + .rounded(px(4.)) + .cursor_pointer() + .hover(|row| row.bg(palette.muted.opacity(0.4 * 0.1))) + .tooltip(tip(label)) + .on_click(toggle) + .child(div().min_w_0().flex_1().children(description(palette))) + .child( + div() + .flex() + .items_center() + .h(px(20.)) + .child(chevron(entry.open, palette)), + ), + ); + } else { + text = text.children(description(palette)).child( + h_flex() + .id(("toast-toggle", id.0)) + .gap(px(4.)) + .py(px(2.)) + .items_center() + .rounded(px(6.)) + .cursor_pointer() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(palette.muted) + .hover(|row| row.text_color(palette.foreground)) + .on_click(toggle) + .child(chevron(entry.open, palette)) + .child(label), + ); + } + if entry.open { + text = text.child(panel(body, true)); + } + } + } + + let mut controls = h_flex() + .items_center() + .gap(px(6.)) + .when(stacked, |row| row.w_full().justify_end()) + .when(!stacked, |row| row.flex_shrink_0()); + if let Some(copy) = toast.copy_text() { + let copy = copy.clone(); + let (label, glyph, color) = if entry.copied { + ("Copied error", Icons::Check, palette.kind(Kind::Success)) + } else { + ("Copy error", Icons::Copy, palette.muted.opacity(0.8)) + }; + controls = controls.child( + div() + .id(("toast-copy", id.0)) + .flex() + .items_center() + .justify_center() + .size(px(20.)) + .rounded(px(4.)) + .cursor_pointer() + .text_color(color) + .when(!entry.copied, |button| { + button.hover(|button| button.text_color(palette.muted)) + }) + .tooltip(tip(label)) + .on_click(cx.listener(move |this, _, _, cx| this.copy(id, copy.clone(), cx))) + .child(Icon::new(glyph).size(px(12.))), + ); + } + for (index, action) in toast.actions.iter().enumerate() { + let on_click = action.on_click.clone(); + let button = Button::new(("toast-action", id.0 * 64 + index as u64)) + .label(action.label.clone()) + .xsmall() + .flex_shrink_0() + .on_click(move |_, window, cx| on_click(window, cx)); + controls = controls.child(match action.variant { + Variant::Primary => button.primary(), + Variant::Outline => button.outline(), + }); + } + + // `Toast.Content`: `pl-3.5`, then `py-3` and `pr-3.5` (`pr-6` beside controls) inline, or + // `py-2.5 pr-3.5` with the controls on their own row when stacked. + let content = if stacked { + v_flex() + .gap(px(8.)) + .pl(px(14.)) + .pr(px(14.)) + .py(px(10.)) + .child(text) + .child(controls) + } else { + h_flex() + .items_center() + .justify_between() + .gap(px(6.)) + .pl(px(14.)) + .py(px(12.)) + .pr(px(if trailing { 24. } else { 14. })) + .child(text) + .children(trailing.then_some(controls)) + }; + + // `toastCornerOrbClass` at `-top-1.5 -right-1.5`. + let close = div() + .id(("toast-close", id.0)) + .absolute() + .top(px(-6.)) + .right(px(-6.)) + .flex() + .items_center() + .justify_center() + .size(px(24.)) + .rounded_full() + .border_1() + .border_color(palette.border) + .bg(palette.popover.opacity(0.92)) + .text_color(palette.muted) + .cursor_pointer() + .shadow_sm() + .hover(|close| close.bg(palette.popover).text_color(palette.foreground)) + .on_click(cx.listener(move |this, _, _, cx| this.dismiss(id, cx))) + .child(Icon::new(Icons::X).size(px(12.))); + + surface(palette) + .id(("toast", id.0)) + .relative() + .w_full() + .shadow(shadow()) + .text_sm() + .line_height(px(20.)) + .text_color(palette.foreground) + .occlude() + .child(close) + .child(content) + .into_any_element() + } +} + +impl Render for ToastHost { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let visible = self.stack.entries.len().min(LIMIT); + if visible == 0 { + return div().into_any_element(); + } + let palette = Palette::new(cx); + let window_width = f32::from(window.viewport_size().width); + let width = toast_width(window_width); + let expanded = self.hovered; + let peeks = if expanded { 0 } else { visible - 1 }; + + let mut stack = v_flex() + .id("toasts") + .absolute() + .top(px(inset(window_width) + self.top_offset)) + .right(px(inset(window_width))) + .w(px(width)) + .on_hover(cx.listener(|this, hovered: &bool, _, cx| { + this.hovered = *hovered; + this.sync_pause(cx); + })); + if expanded { + stack = stack.gap(px(GAP)); + for index in 0..visible { + let entry = &self.stack.entries[index]; + stack = stack.child(self.card(entry, &palette, window, cx)); + } + } else { + // Collapsed, the cards behind the front one are its height, scaled about the top + // center and pushed down by `--toast-peek`: only a strip of each shows under it. + let front = self.card(&self.stack.entries[0], &palette, window, cx); + let mut front_slot = div().relative().w_full().pb(px(PEEK * peeks as f32)); + for index in (1..visible).rev() { + let inset = width * (1. - collapsed_scale(index)) / 2.; + front_slot = front_slot.child( + surface(&palette) + .absolute() + .left(px(inset)) + .right(px(inset)) + .bottom(px(PEEK * (peeks - index) as f32 - 0.)) + .h(px(PEEK + 16.)) + .shadow(shadow()), + ); + } + stack = stack.child(front_slot.child(front)); + } + stack.into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn toast(kind: Option, timeout: Timeout) -> Toast { + let toast = Toast::new("t").timeout(timeout); + match kind { + Some(kind) => toast.kind(kind), + None => toast, + } + } + + #[test] + fn timeouts_follow_base_ui() { + let default = Some(Duration::from_secs(5)); + assert_eq!(Timeout::Default.resolve(None), default); + assert_eq!(Timeout::Never.resolve(None), None); + assert_eq!(Timeout::After(Duration::ZERO).resolve(None), None); + assert_eq!( + Timeout::After(Duration::from_secs(9)).resolve(Some(Kind::Info)), + Some(Duration::from_secs(9)) + ); + assert_eq!(Timeout::Default.resolve(Some(Kind::Loading)), None); + } + + #[test] + fn newest_toast_is_in_front() { + let now = Instant::now(); + let mut stack = Stack::default(); + let first = stack.add(toast(None, Timeout::Never), now); + let second = stack.add(toast(None, Timeout::Never), now); + assert_eq!(stack.entries[0].id, second); + assert_eq!(stack.entries[1].id, first); + assert!(stack.dismiss(second).is_some()); + assert!(stack.dismiss(second).is_none()); + assert!(!stack.update(second, toast(None, Timeout::Never), now)); + } + + #[test] + fn timers_run_expire_and_pause_with_the_remaining_time() { + let start = Instant::now(); + let mut stack = Stack::default(); + let id = stack.add(toast(None, Timeout::Default), start); + let later = |seconds| start + Duration::from_secs(seconds); + assert_eq!(stack.next_expiry(later(2)), Some(Duration::from_secs(3))); + assert!(stack.expired(later(4)).is_empty()); + + // Two seconds run, then a hover holds the toast for as long as it lasts. + stack.set_paused(true, later(2)); + assert_eq!(stack.next_expiry(later(60)), None); + assert!(stack.expired(later(60)).is_empty()); + stack.set_paused(false, later(60)); + assert_eq!(stack.next_expiry(later(61)), Some(Duration::from_secs(2))); + assert_eq!(stack.expired(later(63)), vec![id]); + } + + #[test] + fn a_toast_added_while_paused_waits_for_the_resume() { + let now = Instant::now(); + let mut stack = Stack::default(); + stack.set_paused(true, now); + let id = stack.add(toast(None, Timeout::Default), now); + let later = now + Duration::from_secs(30); + assert!(stack.expired(later).is_empty()); + stack.set_paused(false, later); + assert_eq!(stack.next_expiry(later), Some(Duration::from_secs(5))); + assert_eq!(stack.expired(later + Duration::from_secs(5)), vec![id]); + } + + #[test] + fn update_restarts_the_timer_only_when_its_length_changes() { + let start = Instant::now(); + let later = start + Duration::from_secs(3); + let mut stack = Stack::default(); + let id = stack.add(toast(None, Timeout::Default), start); + assert!(stack.update(id, toast(None, Timeout::Default), later)); + assert_eq!(stack.next_expiry(later), Some(Duration::from_secs(2))); + stack.update( + id, + toast(None, Timeout::After(Duration::from_secs(10))), + later, + ); + assert_eq!(stack.next_expiry(later), Some(Duration::from_secs(10))); + stack.update(id, toast(None, Timeout::Never), later); + assert_eq!(stack.next_expiry(later), None); + } + + #[test] + fn the_stack_scales_and_insets_like_the_web() { + assert_eq!(collapsed_scale(0), 1.); + assert!((collapsed_scale(2) - 0.8).abs() < 1e-6); + assert_eq!(collapsed_scale(12), 0.); + assert_eq!((inset(639.), inset(640.)), (16., 32.)); + assert_eq!(toast_width(1100.), 360.); + assert_eq!(toast_width(500.), 360.); + assert_eq!(toast_width(300.), 268.); + } + + #[test] + fn only_long_error_descriptions_clamp_and_only_errors_copy() { + let long = "x".repeat(ERROR_CLAMP_MIN_CHARS); + assert_eq!(clamp_lines(Some(Kind::Error), &long), Some(4)); + assert_eq!(clamp_lines(Some(Kind::Error), &long[1..]), None); + assert_eq!(clamp_lines(Some(Kind::Info), &long), None); + let error = Toast::new("t").kind(Kind::Error).description("boom"); + assert!(error.has_trailing_controls()); + assert!(!error.clone().hide_copy().has_trailing_controls()); + assert!( + !Toast::new("t") + .kind(Kind::Info) + .description("x") + .has_trailing_controls() + ); + } +} From 542523167821ac193325536d7e6bdbfb0516a42f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 14:59:04 -0500 Subject: [PATCH 38/82] feat(desktop-gpui): slow requests toast from the connection's unary request tracker --- apps/desktop-gpui/src/connection.rs | 29 ++- apps/desktop-gpui/src/main.rs | 2 + apps/desktop-gpui/src/rpc.rs | 39 +++- apps/desktop-gpui/src/slow_requests.rs | 307 +++++++++++++++++++++++++ apps/desktop-gpui/src/status.rs | 8 + apps/desktop-gpui/src/ui.rs | 34 +++ 6 files changed, 412 insertions(+), 7 deletions(-) create mode 100644 apps/desktop-gpui/src/slow_requests.rs diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index d3ca583d41b6..153745287656 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -273,14 +273,23 @@ pub async fn run( }, ); backoff.reset(); - serve( + let failure = serve( socket, environment, actions, (&mut selected, &mut watched, &mut terminal), status, ) - .await + .await; + // The requests died with the socket. + publish( + status, + Status::SlowRequests { + label: environment.label.clone(), + requests: Vec::new(), + }, + ); + failure } Err(message) => message, }; @@ -414,8 +423,24 @@ async fn serve( let mut ping = interval(PING_INTERVAL); ping.set_missed_tick_behavior(MissedTickBehavior::Delay); let mut deadline = Instant::now() + PING_TIMEOUT; + let mut reported_slow = Vec::new(); loop { + let now = std::time::Instant::now(); + let slow = session.slow(now); + if slow != reported_slow { + publish( + status, + Status::SlowRequests { + label: environment.label.clone(), + requests: slow.clone(), + }, + ); + reported_slow = slow; + } + // Parked far out when nothing waits; any frame or action wakes the loop anyway. + let slow_wait = session.next_slow(now).unwrap_or(Duration::from_secs(3600)); tokio::select! { + _ = sleep(slow_wait) => {} _ = ping.tick() => { if let Err(error) = sink.send(Message::text(PING_FRAME)).await { return error.to_string(); diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index effb105b53e0..7a70b8cdf3a0 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -17,6 +17,7 @@ mod refs; mod rpc; mod server; mod shell; +mod slow_requests; mod status; mod strip; mod terminal; @@ -25,6 +26,7 @@ mod terminal_state; mod terminal_view; mod thread; mod thread_card; +mod toast; mod ui; mod usage; mod wire; diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs index 5f104f29c937..ecd925910bc9 100644 --- a/apps/desktop-gpui/src/rpc.rs +++ b/apps/desktop-gpui/src/rpc.rs @@ -1,11 +1,13 @@ //! Effect RPC over the WebSocket in its JSON serialization: request, ack, and response frames. //! Shapes follow effect's `RpcMessage` (`FromClientEncoded`, `FromServerEncoded`). +use crate::slow_requests::{SlowRequest, Tracker}; use crate::wire; use serde::de::DeserializeOwned; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::{Value, json}; use std::collections::HashSet; +use std::time::{Duration, Instant, SystemTime}; /// RpcMessage.constPing in the JSON serialization. pub const PING_FRAME: &str = r#"{"_tag":"Ping"}"#; @@ -13,6 +15,8 @@ pub const PING_FRAME: &str = r#"{"_tag":"Ping"}"#; /// One RPC method: its tag and the generated wire types for its payload and success. pub trait Method { const TAG: &'static str; + /// The call lives as long as its subscription, so it is never a slow request. + const STREAMING: bool = false; type Payload: Serialize; type Success: DeserializeOwned; } @@ -22,6 +26,7 @@ pub struct SubscribeShell; impl Method for SubscribeShell { const TAG: &'static str = "orchestration.subscribeShell"; + const STREAMING: bool = true; type Payload = wire::SubscribeShellPayload; type Success = wire::ShellItem; } @@ -32,6 +37,7 @@ pub struct SubscribeThread; impl Method for SubscribeThread { const TAG: &'static str = "orchestration.subscribeThread"; + const STREAMING: bool = true; type Payload = wire::SubscribeThreadPayload; type Success = Value; } @@ -86,6 +92,7 @@ pub struct SubscribeVcsStatus; impl Method for SubscribeVcsStatus { const TAG: &'static str = "subscribeVcsStatus"; + const STREAMING: bool = true; type Payload = Value; type Success = Value; } @@ -96,6 +103,7 @@ pub struct TerminalAttach; impl Method for TerminalAttach { const TAG: &'static str = "terminal.attach"; + const STREAMING: bool = true; type Payload = Value; type Success = Value; } @@ -159,11 +167,15 @@ untyped_method!( VcsRefreshStatus, "vcs.refreshStatus" ); -untyped_method!( - /// `git.runStackedAction`, a stream of progress events ending with the action's result. - GitRunStackedAction, - "git.runStackedAction" -); +/// `git.runStackedAction`, a stream of progress events ending with the action's result. +pub struct GitRunStackedAction; + +impl Method for GitRunStackedAction { + const TAG: &'static str = "git.runStackedAction"; + const STREAMING: bool = true; + type Payload = Value; + type Success = Value; +} /// Request ids are bigints on the wire, so they travel as strings. fn id_text<'de, D: Deserializer<'de>>(deserializer: D) -> Result { @@ -247,6 +259,7 @@ pub struct Reaction { pub struct Session { next_id: u64, open: HashSet, + slow: Tracker, } impl Session { @@ -266,12 +279,27 @@ impl Session { "headers": [], }); self.open.insert(id.clone()); + if !M::STREAMING { + self.slow + .start(&id, M::TAG, Instant::now(), SystemTime::now()); + } Ok((id, frame.to_string())) } + /// The unary requests still waiting past their threshold, in the order they turned slow. + pub fn slow(&self, now: Instant) -> Vec { + self.slow.slow(now) + } + + /// When the next waiting request turns slow. + pub fn next_slow(&self, now: Instant) -> Option { + self.slow.next_slow(now) + } + /// The frame that stops a stream, as `Fiber.interrupt` does on the web client. pub fn interrupt(&mut self, id: &str) -> String { self.open.remove(id); + self.slow.finish(id); json!({ "_tag": "Interrupt", "requestId": id }).to_string() } @@ -297,6 +325,7 @@ impl Session { } ServerFrame::Exit { request_id, exit } => { self.open.remove(&request_id); + self.slow.finish(&request_id); reaction.events.push(match exit { ExitFrame::Success { value } => Event::Done { request_id, value }, ExitFrame::Failure { cause } => Event::Failed { diff --git a/apps/desktop-gpui/src/slow_requests.rs b/apps/desktop-gpui/src/slow_requests.rs new file mode 100644 index 000000000000..2b10bf65b371 --- /dev/null +++ b/apps/desktop-gpui/src/slow_requests.rs @@ -0,0 +1,307 @@ +//! Requests the server has not answered for too long, and the toast that says so +//! (`rpc/requestLatencyState.ts`, `SlowRpcRequestToastCoordinator.tsx`). + +use crate::toast::{Collapse, Kind, Timeout, Toast}; +use gpui::{FontWeight, ParentElement, Styled, div, prelude::*, px}; +use gpui_component::{ActiveTheme, v_flex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +/// `DEFAULT_SLOW_RPC_ACK_THRESHOLD_MS`. +const DEFAULT_THRESHOLD: Duration = Duration::from_secs(15); +/// `LONG_RUNNING_SLOW_RPC_ACK_THRESHOLD_MS`. +const LONG_RUNNING_THRESHOLD: Duration = Duration::from_secs(120); +const LONG_RUNNING: [&str; 4] = [ + "server.updateProvider", + "server.refreshProviders", + "server.updateServer", + "vcs.refreshStatus", +]; +const UNTRACKED: [&str; 3] = [ + "server.getUsageSummary", + "server.reportClientActivity", + "sourceControl.signInWait", +]; +/// `MAX_TRACKED_REQUESTS`. +const MAX_TRACKED: usize = 256; + +fn threshold(method: &str) -> Option { + if UNTRACKED.contains(&method) + || method.contains("subscribe") + || method.starts_with("pullRequests.") + { + return None; + } + Some(if LONG_RUNNING.contains(&method) { + LONG_RUNNING_THRESHOLD + } else { + DEFAULT_THRESHOLD + }) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SlowRequest { + pub id: String, + pub method: &'static str, + /// Wall clock, for the "Started" line. + pub started_at: SystemTime, + pub threshold: Duration, +} + +struct Tracked { + request: SlowRequest, + started: Instant, +} + +impl Tracked { + fn turns_slow(&self) -> Instant { + self.started + self.request.threshold + } +} + +/// The unary requests sent and not yet answered. Streams are never started here. +#[derive(Default)] +pub struct Tracker { + pending: Vec, +} + +impl Tracker { + pub fn start(&mut self, id: &str, method: &'static str, now: Instant, wall: SystemTime) { + let Some(threshold) = threshold(method) else { + return; + }; + if self.pending.len() >= MAX_TRACKED { + self.pending.remove(0); + } + self.pending.push(Tracked { + request: SlowRequest { + id: id.to_string(), + method, + started_at: wall, + threshold, + }, + started: now, + }); + } + + pub fn finish(&mut self, id: &str) { + self.pending.retain(|tracked| tracked.request.id != id); + } + + /// Those past their threshold, in the order they turned slow. + pub fn slow(&self, now: Instant) -> Vec { + let mut slow: Vec<&Tracked> = self + .pending + .iter() + .filter(|tracked| tracked.turns_slow() <= now) + .collect(); + slow.sort_by_key(|tracked| tracked.turns_slow()); + slow.into_iter() + .map(|tracked| tracked.request.clone()) + .collect() + } + + /// How long until the next waiting request turns slow. + pub fn next_slow(&self, now: Instant) -> Option { + self.pending + .iter() + .map(Tracked::turns_slow) + .filter(|turns_slow| *turns_slow > now) + .min() + .map(|turns_slow| turns_slow - now) + } +} + +/// `${count} request${s} waiting longer than ${Math.round(min threshold / 1000)}s.` +fn describe(requests: &[SlowRequest]) -> String { + let seconds = requests + .iter() + .map(|request| request.threshold.as_millis()) + .min() + .map_or(0, |millis| (millis + 500) / 1000); + format!( + "{} request{} waiting longer than {seconds}s.", + requests.len(), + if requests.len() == 1 { "" } else { "s" } + ) +} + +/// `Date#toLocaleTimeString` in en-US, as `3:07:09 PM`. +fn format_clock(hour: u32, minute: u32, second: u32) -> String { + let twelve = match hour % 12 { + 0 => 12, + hour => hour, + }; + let meridiem = if hour < 12 { "AM" } else { "PM" }; + format!("{twelve}:{minute:02}:{second:02} {meridiem}") +} + +fn local_clock(time: SystemTime) -> String { + let seconds = time + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_secs()) as libc::time_t; + // SAFETY: `localtime_r` writes only into the zeroed `tm` it is given. + let tm = unsafe { + let mut tm: libc::tm = std::mem::zeroed(); + if libc::localtime_r(&seconds, &mut tm).is_null() { + return String::new(); + } + tm + }; + format_clock(tm.tm_hour as u32, tm.tm_min as u32, tm.tm_sec as u32) +} + +/// The one warning toast, with the waiting requests in its collapsible body. +pub fn toast(label: &str, requests: &[SlowRequest]) -> Toast { + let items: Vec<(String, String)> = requests + .iter() + .map(|request| { + ( + format!("{} \u{b7} {label}", request.method), + format!("Started {}", local_clock(request.started_at)), + ) + }) + .collect(); + Toast::new("Some requests are slow") + .kind(Kind::Warning) + .description(describe(requests)) + .timeout(Timeout::Never) + .collapse(Collapse::new("Show requests", "Hide requests").on_description()) + .body(move |_, cx| { + let border = cx.theme().border.opacity(0.5); + let muted = cx.theme().muted_foreground; + let foreground = cx.theme().foreground; + let last = items.len().saturating_sub(1); + v_flex() + .gap(px(10.)) + .text_xs() + .text_color(muted) + .children(items.iter().enumerate().map(|(index, (tag, started))| { + v_flex() + .min_w_0() + .when(index != last, |item| { + item.border_b_1().border_color(border).pb(px(8.)) + }) + .child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child(tag.clone()), + ) + .child( + div() + .mt(px(2.)) + .text_size(px(10.)) + .line_height(px(14.)) + .opacity(0.75) + .child(started.clone()), + ) + })) + .into_any_element() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn secs(seconds: u64) -> Duration { + Duration::from_secs(seconds) + } + + fn started(tracker: &mut Tracker, id: &str, method: &'static str, at: Instant) { + tracker.start(id, method, at, SystemTime::UNIX_EPOCH); + } + + fn ids(requests: Vec) -> Vec { + requests.into_iter().map(|request| request.id).collect() + } + + #[test] + fn a_request_is_slow_after_its_threshold_until_it_finishes() { + let start = Instant::now(); + let mut tracker = Tracker::default(); + started(&mut tracker, "1", "projects.mutate", start); + assert!(tracker.slow(start + secs(14)).is_empty()); + assert_eq!(tracker.next_slow(start + secs(14)), Some(secs(1))); + assert_eq!(ids(tracker.slow(start + secs(15))), ["1"]); + assert_eq!(tracker.next_slow(start + secs(15)), None); + tracker.finish("1"); + assert!(tracker.slow(start + secs(99)).is_empty()); + } + + #[test] + fn long_running_methods_get_the_longer_threshold() { + let start = Instant::now(); + let mut tracker = Tracker::default(); + started(&mut tracker, "1", "vcs.refreshStatus", start); + assert!(tracker.slow(start + secs(119)).is_empty()); + assert_eq!(tracker.slow(start + secs(120))[0].threshold, secs(120)); + } + + #[test] + fn exempt_methods_are_not_tracked() { + let start = Instant::now(); + let mut tracker = Tracker::default(); + for method in [ + "orchestration.subscribeShell", + "subscribeVcsStatus", + "pullRequests.list", + "server.getUsageSummary", + "server.reportClientActivity", + "sourceControl.signInWait", + ] { + started(&mut tracker, "1", method, start); + } + assert!(tracker.slow(start + secs(1000)).is_empty()); + } + + #[test] + fn slow_requests_are_ordered_by_when_they_turned_slow() { + let start = Instant::now(); + let mut tracker = Tracker::default(); + started(&mut tracker, "long", "vcs.refreshStatus", start); + started(&mut tracker, "late", "projects.mutate", start + secs(10)); + started(&mut tracker, "early", "projects.mutate", start); + assert_eq!( + ids(tracker.slow(start + secs(200))), + ["early", "late", "long"] + ); + } + + #[test] + fn the_oldest_request_is_dropped_past_the_cap() { + let start = Instant::now(); + let mut tracker = Tracker::default(); + for id in 0..=MAX_TRACKED { + started(&mut tracker, &id.to_string(), "projects.mutate", start); + } + let slow = ids(tracker.slow(start + secs(15))); + assert_eq!(slow.len(), MAX_TRACKED); + assert!(!slow.contains(&"0".to_string())); + } + + #[test] + fn the_description_counts_and_names_the_shortest_threshold() { + let request = |threshold| SlowRequest { + id: String::new(), + method: "m", + started_at: SystemTime::UNIX_EPOCH, + threshold, + }; + assert_eq!( + describe(&[request(secs(15))]), + "1 request waiting longer than 15s." + ); + assert_eq!( + describe(&[request(secs(120)), request(secs(15))]), + "2 requests waiting longer than 15s." + ); + } + + #[test] + fn the_clock_reads_like_en_us() { + assert_eq!(format_clock(0, 5, 9), "12:05:09 AM"); + assert_eq!(format_clock(12, 0, 0), "12:00:00 PM"); + assert_eq!(format_clock(15, 7, 9), "3:07:09 PM"); + } +} diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index 9d8b2a951a9d..b896f0d1ef11 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -2,6 +2,7 @@ use crate::details::VcsStatus; use crate::diff; use crate::projects::{EnvironmentProjects, MachineKind, ProjectEntry}; use crate::providers::Catalog; +use crate::slow_requests::SlowRequest; use crate::strip::Branch; use crate::terminal; use crate::thread::Entry; @@ -55,6 +56,12 @@ pub enum Status { cwd: String, diff: Result, }, + /// The primary connection's unary requests that have waited past their threshold, in the + /// order they turned slow; empty when none has, and when the socket is gone. + SlowRequests { + label: String, + requests: Vec, + }, /// The outcome of a request the details card made, by the token it was sent with. Call { token: u64, @@ -222,6 +229,7 @@ impl fmt::Display for Status { } => write!(f, "Terminal {terminal_id} of {thread_id}"), Self::Diff { cwd, .. } => write!(f, "Diff of {cwd}"), Self::Call { token, .. } => write!(f, "Call {token}"), + Self::SlowRequests { requests, .. } => write!(f, "SlowRequests {}", requests.len()), } } } diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 17bad3743c4d..0c2a6522f3dd 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -6,6 +6,7 @@ use crate::history::History; use crate::minimap; use crate::projects::{self, EnvironmentProjects, MachineKind, ProjectEntry}; use crate::providers::{Catalog, Mark, Trigger}; +use crate::slow_requests; use crate::status::{ LastError, PrBadge, PrState, Section, ShellView, Status, ThreadRow, TopStatus, }; @@ -18,6 +19,7 @@ use crate::terminal_state::{self, Placement, Terminals}; use crate::terminal_view::TerminalView; use crate::thread::{Entry, Kind}; use crate::thread_card::{self, ChainKind, PrLine}; +use crate::toast::{ToastHost, ToastId}; use gpui::{ Anchor, Animation, AnimationExt as _, AnyElement, App, Bounds, Context, DragMoveEvent, Entity, Focusable as _, FollowMode, FontWeight, Hsla, IntoElement, Keystroke, ListAlignment, @@ -238,6 +240,9 @@ pub struct MainView { /// The terminals the connection was last asked to attach. terminal_attached: Vec, card: Entity, + toasts: Entity, + /// The slow requests toast; kept after the user dismisses it, like the web coordinator. + slow_toast: Option, /// The main area's width at the last render, for fitting the details card. main_width: Cell, actions: ActionSender, @@ -327,12 +332,40 @@ impl MainView { terminal_views: HashMap::new(), terminal_attached: Vec::new(), card, + toasts: cx.new(|cx| ToastHost::new(TOPBAR_HEIGHT, window, cx)), + slow_toast: None, main_width: Cell::new(0.), actions, } } + /// `SlowRpcRequestToastCoordinator`: one toast for the list, shown, updated and closed with it. + fn sync_slow_toast( + &mut self, + label: &str, + requests: &[slow_requests::SlowRequest], + cx: &mut Context, + ) { + let toast = slow_requests::toast(label, requests); + self.toasts.update(cx, |toasts, cx| { + match (requests.is_empty(), self.slow_toast) { + (true, id) => { + if let Some(id) = id { + toasts.dismiss(id, cx); + } + self.slow_toast = None; + } + (false, None) => self.slow_toast = Some(toasts.add(toast, cx)), + (false, Some(id)) => toasts.update(id, toast, cx), + } + }); + } + pub fn set_status(&mut self, status: Status, cx: &mut Context) { + if let Status::SlowRequests { label, requests } = status { + self.sync_slow_toast(&label, &requests, cx); + return; + } if let Status::Environment(other) = status { match self.environments.iter_mut().find(|e| e.id == other.id) { Some(known) => *known = other, @@ -4048,6 +4081,7 @@ impl Render for MainView { .when(overlay, |root| { root.child(self.sidebar_drawer(overlay_width(width), window, cx)) }) + .child(self.toasts.clone()) } } From 6d8aad2c11a0e814919460d4b818164367406d63 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 16:59:01 -0500 Subject: [PATCH 39/82] feat(desktop-gpui): wire sidebar header add project, project filter and new thread --- apps/desktop-gpui/src/connection.rs | 147 +++++++- apps/desktop-gpui/src/main.rs | 3 + apps/desktop-gpui/src/projects.rs | 198 ++++++++++ apps/desktop-gpui/src/status.rs | 6 + apps/desktop-gpui/src/ui.rs | 477 ++++++++++++++++++++++-- apps/desktop-gpui/src/ui/right_panel.rs | 11 +- apps/desktop-gpui/src/wire/mod.rs | 1 + 7 files changed, 804 insertions(+), 39 deletions(-) diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 153745287656..1829dcf457fd 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -18,7 +18,8 @@ use crate::strip::{self, Branch}; use crate::terminal::{self, Grid, Target}; use crate::thread::ThreadState; use crate::wire::{ - ProjectMutation, ProjectMutationType, ShellItem, SubscribeShellPayload, SubscribeThreadPayload, + ProjectMutation, ProjectMutationCreateType, ProjectMutationType, ShellItem, + SubscribeShellPayload, SubscribeThreadPayload, }; use futures_util::stream::SplitSink; use futures_util::{SinkExt, StreamExt}; @@ -50,6 +51,13 @@ pub enum Action { project_id: String, pinned: bool, }, + /// Registers a folder as a project; the outcome comes back as `Status::ProjectAdded` or + /// `Status::ProjectFailed`. + AddProject { + project_id: String, + title: String, + workspace_root: String, + }, /// The branch checked out in a project's workspace root, for the strip under the composer. LoadBranch { root: String, @@ -112,6 +120,7 @@ impl Action { match self { Self::SetProjectPinned { pinned: true, .. } => "pin project", Self::SetProjectPinned { pinned: false, .. } => "unpin project", + Self::AddProject { .. } => "add the project", Self::LoadBranch { .. } => "load the branch", Self::SelectThread(_) => "open the thread", Self::WatchVcs(_) => "watch the version control status", @@ -172,6 +181,25 @@ fn pin_mutation(project_id: &str, pinned: bool, command_id: String) -> ProjectMu } } +fn create_mutation( + project_id: &str, + title: &str, + workspace_root: &str, + command_id: String, +) -> ProjectMutation { + ProjectMutation::Variant0 { + command_id, + // The web's add project flow creates a folder that is missing. + create_workspace_root_if_missing: Some(true), + default_model_selection: None, + project_id: project_id.to_owned(), + scripts: None, + title: title.to_owned(), + type_: ProjectMutationCreateType::ProjectCreate, + workspace_root: workspace_root.to_owned(), + } +} + fn refs_query(root: &str) -> serde_json::Value { serde_json::json!({"cwd": root, "refKind": "local", "limit": strip::REFS_LIMIT}) } @@ -420,6 +448,8 @@ async fn serve( let mut diffs: HashMap = HashMap::new(); // The card's calls by request id, with the token its answers carry. let mut calls: HashMap = HashMap::new(); + // Add project requests by request id, with the project id each created. + let mut adds: HashMap = HashMap::new(); let mut ping = interval(PING_INTERVAL); ping.set_missed_tick_behavior(MissedTickBehavior::Delay); let mut deadline = Instant::now() + PING_TIMEOUT; @@ -543,6 +573,24 @@ async fn serve( Action::SetProjectPinned { project_id, pinned } => session .request::(&pin_mutation(project_id, *pinned, command_id())) .map(|(id, frame)| (id, frame, None)), + Action::AddProject { + project_id, + title, + workspace_root, + } => { + let mutation = + create_mutation(project_id, title, workspace_root, command_id()); + match session.request::(&mutation) { + Ok((id, frame)) => { + adds.insert(id, project_id.clone()); + if let Err(error) = sink.send(Message::text(frame)).await { + return error.to_string(); + } + } + Err(error) => publish(status, Status::ProjectFailed(error)), + } + continue; + } Action::LoadBranch { root } => session .request::(&refs_query(root)) .map(|(id, frame)| (id, frame, Some(root.clone()))), @@ -608,6 +656,9 @@ async fn serve( publish_branch(root, event, status); continue; } + if route_add(&mut adds, &event, status) { + continue; + } if settle(&mut pending, &event) { continue; } @@ -906,6 +957,28 @@ impl Terminals { } } +/// A project id in the UUID v4 text form the web mints. +pub fn new_project_id() -> String { + let mut bytes = [0u8; 16]; + // A failed read leaves zeros, and the server rejects an id that is already a project. + let _ = getrandom::fill(&mut bytes); + uuid_v4(bytes) +} + +fn uuid_v4(mut bytes: [u8; 16]) -> String { + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + let hex = hex::encode(bytes); + format!( + "{}-{}-{}-{}-{}", + &hex[..8], + &hex[8..12], + &hex[12..16], + &hex[16..20], + &hex[20..] + ) +} + fn command_id() -> String { let mut bytes = [0u8; 8]; // A failed read leaves zeros, and the server rejects a repeated command id rather than applying it twice. @@ -1014,6 +1087,24 @@ fn publish_catalog(event: Event, status: &StatusSender) { } } +/// Publishes the outcome of an add project request; true when `event` was one. +fn route_add(adds: &mut HashMap, event: &Event, status: &StatusSender) -> bool { + match event { + Event::Done { request_id, .. } => adds.remove(request_id).is_some_and(|project_id| { + publish(status, Status::ProjectAdded(project_id)); + true + }), + Event::Failed { + request_id, + message, + } => adds.remove(request_id).is_some_and(|_| { + publish(status, Status::ProjectFailed(message.clone())); + true + }), + _ => false, + } +} + /// Consumes the outcome of an action's request, logging a failure; true when `event` was one. fn settle(pending: &mut HashMap, event: &Event) -> bool { match event { @@ -1074,6 +1165,60 @@ mod tests { assert_eq!(backoff.next_delay().as_millis(), 500); } + #[test] + fn project_ids_have_the_uuid_v4_shape() { + assert_eq!(uuid_v4([0xff; 16]), "ffffffff-ffff-4fff-bfff-ffffffffffff"); + } + + #[test] + fn add_action_builds_a_project_create_mutation() { + let value = serde_json::to_value(create_mutation( + "p1", + "t3code", + "/work/t3code", + "c1".to_owned(), + )) + .expect("json"); + assert_eq!( + value, + serde_json::json!({ + "commandId": "c1", + "createWorkspaceRootIfMissing": true, + "projectId": "p1", + "title": "t3code", + "type": "project.create", + "workspaceRoot": "/work/t3code", + }) + ); + } + + #[test] + fn route_add_consumes_only_the_outcome_of_an_add() { + let (status, receiver) = async_channel::unbounded(); + let mut adds = HashMap::from([("7".to_owned(), "p1".to_owned())]); + let done = Event::Done { + request_id: "7".to_owned(), + value: serde_json::Value::Null, + }; + let failed = |id: &str| Event::Failed { + request_id: id.to_owned(), + message: "boom".to_owned(), + }; + assert!(!route_add(&mut adds, &failed("0"), &status)); + assert!(route_add(&mut adds, &done, &status)); + assert_eq!( + receiver.try_recv().ok(), + Some(Status::ProjectAdded("p1".to_owned())) + ); + adds.insert("8".to_owned(), "p2".to_owned()); + assert!(route_add(&mut adds, &failed("8"), &status)); + assert_eq!( + receiver.try_recv().ok(), + Some(Status::ProjectFailed("boom".to_owned())) + ); + assert!(adds.is_empty()); + } + #[test] fn pin_action_builds_a_project_update_mutation() { let value = serde_json::to_value(pin_mutation("p1", true, "c1".to_owned())).expect("json"); diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 7a70b8cdf3a0..7d0247e6e949 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -112,6 +112,9 @@ fn main() { KeyBinding::new("cmd-j", ui::ToggleTerminal, None), KeyBinding::new("alt-cmd-b", ui::ToggleRightPanel, None), KeyBinding::new("cmd-o", ui::OpenFavoriteEditor, None), + // `chat.new` and `chat.newWithoutProject`; `terminal.new` wins while a terminal has focus. + KeyBinding::new("cmd-n", ui::NewThread, None), + KeyBinding::new("alt-cmd-n", ui::NewScratchThread, None), KeyBinding::new("cmd-[", ui::GoBack, None), KeyBinding::new("cmd-]", ui::GoForward, None), // The web's `terminal.*` defaults, which apply while the terminal has focus. diff --git a/apps/desktop-gpui/src/projects.rs b/apps/desktop-gpui/src/projects.rs index 92489def72a8..34fb9877e6f0 100644 --- a/apps/desktop-gpui/src/projects.rs +++ b/apps/desktop-gpui/src/projects.rs @@ -3,8 +3,10 @@ //! `sortLogicalProjectsForSidebar` (Sidebar.logic.ts) and `buildSidebarProjectPickerEntries` //! (sidebarProjectGrouping.ts). Each row is a group of projects that share a repository. +use crate::status::ThreadRow; use crate::wire::generated::RepositoryIdentity; use crate::wire::{Project, Thread}; +use std::borrow::Cow; use std::collections::HashMap; /// `[name, light, dark]` as sRGB hex: Tailwind's 600 and 400 shades, in `PROJECT_ICON_COLORS` order. @@ -519,6 +521,99 @@ pub fn order_while_open<'a>( ordered } +/// The sidebar's project scope, `resolveSidebarProjectScopeKeys`: the chosen group keys that +/// still name a listed project, so a scope whose project is gone reads as all projects. +pub fn resolve_scope(entries: &[ProjectEntry], keys: &[String]) -> Vec { + keys.iter() + .filter(|key| { + entries + .iter() + .any(|entry| !entry.scratch && entry.key == **key) + }) + .cloned() + .collect() +} + +/// A scope pick: a plain pick narrows to that project, a shift pick adds or removes it +/// (`toggleProjectScopeKey`), and `None` is the "All projects" row. +pub fn pick_scope(scope: &[String], key: Option<&str>, additive: bool) -> Vec { + let Some(key) = key else { + return Vec::new(); + }; + if !additive { + return vec![key.to_owned()]; + } + if scope.iter().any(|chosen| chosen == key) { + scope + .iter() + .filter(|chosen| *chosen != key) + .cloned() + .collect() + } else { + scope.iter().cloned().chain([key.to_owned()]).collect() + } +} + +/// Whether the scope lists a thread of `project_id`; an empty scope lists every thread. +pub fn in_scope(entries: &[ProjectEntry], scope: &[String], project_id: &str) -> bool { + scope.is_empty() + || entries + .iter() + .any(|entry| scope.contains(&entry.key) && entry.contains(project_id)) +} + +/// The rows the sidebar lists under the scope; borrowed when nothing is scoped. +pub fn scoped_rows<'a>( + rows: &'a [ThreadRow], + entries: &[ProjectEntry], + scope: &[String], +) -> Cow<'a, [ThreadRow]> { + if scope.is_empty() { + return Cow::Borrowed(rows); + } + Cow::Owned( + rows.iter() + .filter(|row| in_scope(entries, scope, &row.project_id)) + .cloned() + .collect(), + ) +} + +/// A folder picked for a new project: `inferProjectTitleFromPath` and the dispatch path. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct NewProject { + pub title: String, + pub workspace_root: String, +} + +impl NewProject { + /// `None` for an empty path. Trailing separators go, as the web normalizes the dispatch path. + pub fn from_path(path: &str) -> Option { + let trimmed = path.trim(); + let root = match trimmed.trim_end_matches(['/', '\\']) { + "" if trimmed.is_empty() => return None, + "" => "/", + root => root, + }; + let title = root + .rsplit(['/', '\\']) + .find(|segment| !segment.is_empty()) + .unwrap_or(root); + Some(Self { + title: title.to_owned(), + workspace_root: root.to_owned(), + }) + } +} + +/// The listed project already rooted at `root`, `findProjectByPath` over the group representatives. +pub fn project_at<'a>(entries: &'a [ProjectEntry], root: &str) -> Option<&'a ProjectEntry> { + let wanted = normalize_path(root); + entries.iter().find(|entry| { + !entry.scratch && !entry.root.is_empty() && normalize_path(&entry.root) == wanted + }) +} + #[cfg(test)] mod tests { use super::*; @@ -556,6 +651,109 @@ mod tests { ); } + fn scoped_entry(key: &str, members: &[&str], root: &str, scratch: bool) -> ProjectEntry { + ProjectEntry { + key: key.to_owned(), + id: members[0].to_owned(), + member_ids: members.iter().map(|id| (*id).to_owned()).collect(), + title: key.to_owned(), + initials: String::new(), + color: 0, + pinned: false, + scratch, + root: root.to_owned(), + logical_key: None, + remote: Vec::new(), + } + } + + fn thread_in(project_id: &str) -> ThreadRow { + ThreadRow { + id: format!("thread-{project_id}"), + project_id: project_id.to_owned(), + project: project_id.to_owned(), + title: String::new(), + branch: None, + worktree: false, + provider_instance: String::new(), + top: None, + pr: None, + section: crate::status::Section::Active, + model: String::new(), + instance_history: Vec::new(), + pr_lines: Vec::new(), + last_error: None, + worktree_path: None, + has_run: false, + } + } + + #[test] + fn scope_picks_narrow_toggle_and_clear() { + let scope = ["a".to_owned()]; + assert_eq!(pick_scope(&scope, Some("b"), false), ["b"]); + assert_eq!(pick_scope(&scope, Some("b"), true), ["a", "b"]); + assert_eq!(pick_scope(&scope, Some("a"), true), Vec::::new()); + assert_eq!(pick_scope(&scope, None, true), Vec::::new()); + } + + #[test] + fn scope_lists_the_threads_of_every_member_of_a_chosen_group() { + let entries = [ + scoped_entry("repo", &["p1", "p2"], "/work/repo", false), + scoped_entry("other", &["p3"], "/work/other", false), + ]; + let rows = [thread_in("p1"), thread_in("p2"), thread_in("p3")]; + let scope = ["repo".to_owned()]; + let ids = |listed: Cow<[ThreadRow]>| -> Vec { + listed.iter().map(|row| row.project_id.clone()).collect() + }; + assert_eq!(ids(scoped_rows(&rows, &entries, &scope)), ["p1", "p2"]); + assert!(matches!( + scoped_rows(&rows, &entries, &[]), + Cow::Borrowed(_) + )); + assert!(in_scope(&entries, &[], "p3")); + assert!(!in_scope(&entries, &scope, "p3")); + } + + #[test] + fn a_scope_drops_keys_that_no_longer_name_a_project() { + let entries = [ + scoped_entry("a", &["a"], "/a", false), + scoped_entry("scratch", &["s"], "", true), + ]; + let keys = ["gone", "a", "scratch"].map(str::to_owned); + assert_eq!(resolve_scope(&entries, &keys), ["a"]); + } + + #[test] + fn a_new_project_takes_its_title_from_the_last_path_segment() { + let new = NewProject::from_path("/Users/me/code/t3code/").expect("path"); + assert_eq!(new.title, "t3code"); + assert_eq!(new.workspace_root, "/Users/me/code/t3code"); + let windows = NewProject::from_path("C:\\work\\app\\").expect("path"); + assert_eq!(windows.title, "app"); + assert_eq!( + NewProject::from_path("/").expect("root").workspace_root, + "/" + ); + assert_eq!(NewProject::from_path(" "), None); + } + + #[test] + fn an_added_folder_matches_a_listed_project_ignoring_trailing_separators() { + let entries = [ + scoped_entry("scratch", &["s"], "/work/app", true), + scoped_entry("app", &["p1"], "/work/app", false), + ]; + assert_eq!( + project_at(&entries, "/work/app/").map(|e| e.key.as_str()), + Some("app") + ); + assert!(project_at(&entries, "/work/other").is_none()); + } + #[test] fn pin_tooltip_names_the_action_and_shortcut() { assert_eq!( diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index b896f0d1ef11..ac861b40ea3c 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -62,6 +62,10 @@ pub enum Status { label: String, requests: Vec, }, + /// The server created the project; the shell lists it once its delta lands. + ProjectAdded(String), + /// Why adding a project failed. + ProjectFailed(String), /// The outcome of a request the details card made, by the token it was sent with. Call { token: u64, @@ -229,6 +233,8 @@ impl fmt::Display for Status { } => write!(f, "Terminal {terminal_id} of {thread_id}"), Self::Diff { cwd, .. } => write!(f, "Diff of {cwd}"), Self::Call { token, .. } => write!(f, "Call {token}"), + Self::ProjectAdded(id) => write!(f, "ProjectAdded {id}"), + Self::ProjectFailed(message) => write!(f, "ProjectFailed {message}"), Self::SlowRequests { requests, .. } => write!(f, "SlowRequests {}", requests.len()), } } diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 0c2a6522f3dd..df5c567b5e78 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -1,10 +1,10 @@ use crate::assets::{MESSAGE_SQUARE_DASHED, STAGE_DEV, STAGE_DEV_BUTTON, provider_icon}; -use crate::connection::{Action, ActionSender}; +use crate::connection::{Action, ActionSender, new_project_id}; use crate::details::{self, VcsStatus}; use crate::details_card::{DetailsCard, OpenDiff, Props}; use crate::history::History; use crate::minimap; -use crate::projects::{self, EnvironmentProjects, MachineKind, ProjectEntry}; +use crate::projects::{self, EnvironmentProjects, MachineKind, NewProject, ProjectEntry}; use crate::providers::{Catalog, Mark, Trigger}; use crate::slow_requests; use crate::status::{ @@ -19,13 +19,13 @@ use crate::terminal_state::{self, Placement, Terminals}; use crate::terminal_view::TerminalView; use crate::thread::{Entry, Kind}; use crate::thread_card::{self, ChainKind, PrLine}; -use crate::toast::{ToastHost, ToastId}; +use crate::toast::{Kind as ToastKind, Toast, ToastHost, ToastId}; use gpui::{ Anchor, Animation, AnimationExt as _, AnyElement, App, Bounds, Context, DragMoveEvent, Entity, Focusable as _, FollowMode, FontWeight, Hsla, IntoElement, Keystroke, ListAlignment, ListOffset, ListState, MouseButton, MouseDownEvent, MouseMoveEvent, ObjectFit, ParentElement, - Pixels, Render, RenderOnce, SharedString, Styled, Window, actions, canvas, div, ease_in_out, - img, list, prelude::*, px, rgb, + PathPromptOptions, Pixels, Render, RenderOnce, SharedString, Styled, Window, actions, canvas, + div, ease_in_out, img, list, prelude::*, px, rgb, }; use gpui_component::{ ActiveTheme, Icon, IconName, Selectable, Sizable, TitleBar, @@ -83,7 +83,9 @@ actions!( NewTerminal, CloseTerminal, ToggleRightPanel, - OpenFavoriteEditor + OpenFavoriteEditor, + NewThread, + NewScratchThread ] ); @@ -204,6 +206,12 @@ pub struct MainView { selected_project: Option, history: History, picker_open: bool, + /// The sidebar's project scope as group keys, empty for all projects; in memory only. + project_scope: Vec, + scope_open: bool, + scope_search: Entity, + /// The project the server was asked to create, opened once the shell lists it. + adding_project: Option, /// Last painted trigger bounds, so the popup can shrink to fit below it as on the web. picker_trigger: Rc>>, picker_order: Option>, @@ -263,6 +271,14 @@ impl MainView { } }) .detach(); + let scope_search = + cx.new(|cx| InputState::new(window, cx).placeholder("Search projects...")); + cx.subscribe(&scope_search, |_, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }) + .detach(); let composer = cx.new(|cx| { TextareaState::new(window, cx) .placeholder("Ask anything. @ files, $ skills, / commands") @@ -312,6 +328,10 @@ impl MainView { selected_project: None, history: History::default(), picker_open: false, + project_scope: Vec::new(), + scope_open: false, + scope_search, + adding_project: None, picker_trigger: Rc::default(), picker_order: None, environments: Vec::new(), @@ -362,6 +382,15 @@ impl MainView { } pub fn set_status(&mut self, status: Status, cx: &mut Context) { + if let Status::ProjectFailed(message) = status { + self.adding_project = None; + self.error_toast("Failed to add project", message, cx); + return; + } + if let Status::ProjectAdded(id) = status { + self.adding_project = Some(id); + return self.open_added_project(cx); + } if let Status::SlowRequests { label, requests } = status { self.sync_slow_toast(&label, &requests, cx); return; @@ -439,6 +468,7 @@ impl MainView { }); } } + self.open_added_project(cx); self.request_branch(); self.sync_vcs(); self.sync_terminal(cx); @@ -446,6 +476,32 @@ impl MainView { cx.notify(); } + fn error_toast(&mut self, title: &str, description: String, cx: &mut Context) { + let toast = Toast::new(title.to_owned()) + .kind(ToastKind::Error) + .description(description); + self.toasts.update(cx, |toasts, cx| { + toasts.add(toast, cx); + }); + } + + /// Opens a new thread in the project the server created, as the web does once it exists. + fn open_added_project(&mut self, cx: &mut Context) { + let Some(id) = self.adding_project.clone() else { + return; + }; + let listed = self.shell().is_some_and(|shell| { + shell + .project_entries + .iter() + .any(|entry| entry.contains(&id)) + }); + if listed { + self.adding_project = None; + self.select_project(id, cx); + } + } + /// Keeps the rows of the open thread; an update for any other thread is stale. fn set_transcript(&mut self, id: String, result: Result, String>) { if self.selected_thread.as_deref() != Some(id.as_str()) { @@ -494,6 +550,22 @@ impl MainView { fn apply_route(&mut self, route: Route, cx: &mut Context) { self.sidebar_overlay_open = false; + // A thread the scope hides widens it to all projects, like the web's route reveal. + let hidden = self + .shell() + .zip(route.thread.as_deref()) + .is_some_and(|(shell, id)| { + shell + .rows + .iter() + .find(|row| row.id == id) + .is_some_and(|row| { + !projects::in_scope(&shell.project_entries, &self.scope(), &row.project_id) + }) + }); + if hidden { + self.project_scope.clear(); + } self.selected_project = route.project; self.selected_thread = route.thread; self.sync_thread(); @@ -537,6 +609,89 @@ impl MainView { self.apply_route(route, cx); } + /// The `chat.newLocal` command: a draft in the open thread's project, else the selected one. + fn new_thread(&mut self, cx: &mut Context) { + if self + .shell() + .is_some_and(|shell| !shell.project_entries.is_empty()) + { + self.open_thread(None, cx); + } + } + + /// The `chat.newWithoutProject` command: a draft in the managed scratch project. + fn new_scratch_thread(&mut self, cx: &mut Context) { + let scratch = self + .shell() + .and_then(|shell| scratch_entry(&shell.project_entries)) + .map(|entry| entry.id.clone()); + if let Some(id) = scratch { + self.select_project(id, cx); + } + } + + /// The web's add project flow with the native folder picker standing in for its browse step. + fn add_project(&mut self, cx: &mut Context) { + let picked = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Add project".into()), + }); + cx.spawn(async move |this, cx| { + let result = picked.await; + this.update(cx, |this, cx| match result { + Ok(Ok(Some(paths))) => { + if let Some(path) = paths.first() { + this.create_project(&path.to_string_lossy(), cx); + } + } + Ok(Err(error)) => this.error_toast("Failed to add project", error.to_string(), cx), + _ => {} + }) + .ok(); + }) + .detach(); + } + + /// A folder that is already a project opens a new thread in it; any other becomes one. + fn create_project(&mut self, path: &str, cx: &mut Context) { + let Some(new) = NewProject::from_path(path) else { + return; + }; + let Some(shell) = self.shell() else { + return; + }; + if let Some(existing) = projects::project_at(&shell.project_entries, &new.workspace_root) { + let id = existing.id.clone(); + return self.select_project(id, cx); + } + let project_id = new_project_id(); + self.adding_project = Some(project_id.clone()); + let add = Action::AddProject { + project_id, + title: new.title, + workspace_root: new.workspace_root, + }; + if self.actions.try_send(add).is_err() { + eprintln!("[ui] could not add the project"); + } + } + + /// The sidebar's project scope without the projects that are gone. + fn scope(&self) -> Vec { + self.shell().map_or_else(Vec::new, |shell| { + projects::resolve_scope(&shell.project_entries, &self.project_scope) + }) + } + + /// A pick in the scope menu; a plain pick closes it, a shift pick keeps it open to add more. + fn pick_scope(&mut self, key: Option<&str>, additive: bool, cx: &mut Context) { + self.project_scope = projects::pick_scope(&self.scope(), key, additive); + self.scope_open = additive && key.is_some(); + cx.notify(); + } + /// Asks for the branch of the active project's root once per connection. fn request_branch(&mut self) { let Some(entry) = self @@ -915,6 +1070,7 @@ impl MainView { "toggle-terminal", Icons::PanelBottom, format!("Toggle terminal drawer ({})", terminal::TOGGLE_SHORTCUT).into(), + true, cx, ) .when(pressed, move |button| { @@ -931,6 +1087,7 @@ impl MainView { "toggle-details", Icons::SquareMenu, format!("Toggle thread details ({})", details::TOGGLE_SHORTCUT).into(), + true, cx, ) .when(pressed, move |button| { @@ -1246,12 +1403,13 @@ impl MainView { fn sidebar(&self, width: f32, window: &Window, cx: &mut Context) -> impl IntoElement { self.rendered_sidebar_width.set(width); - let shelves = self - .shell() - .map_or_else(Vec::new, |shell| shelves(&shell.rows, self.shelves)); - let top = self + let scope = self.scope(); + let scoped = self .shell() - .map_or_else(Vec::new, |shell| top_rows(&shell.rows)); + .map(|shell| projects::scoped_rows(&shell.rows, &shell.project_entries, &scope)); + let rows: &[ThreadRow] = scoped.as_deref().unwrap_or(&[]); + let shelves = shelves(rows, self.shelves); + let top = top_rows(rows); let mut list = v_flex() .id("threads") .flex_1() @@ -1259,12 +1417,10 @@ impl MainView { .gap_px() .overflow_y_scroll(); for index in top { - if let Some(shell) = self.shell() { - list = list.child(self.thread_item(&shell.rows[index], cx).flex_shrink_0()); - } + list = list.child(self.thread_item(&rows[index], cx).flex_shrink_0()); } for (position, shelf) in shelves.into_iter().enumerate() { - list = list.child(self.shelf(shelf, position == 0, cx)); + list = list.child(self.shelf(shelf, position == 0, rows, cx)); } v_flex() .w(px(width)) @@ -1323,7 +1479,13 @@ impl MainView { /// One collapsible shelf: its header, then its rows while expanded. The first shelf takes /// the free space above it (`mt-auto`), so shelves sit at the bottom until content overflows. - fn shelf(&self, shelf: Shelf, first: bool, cx: &mut Context) -> AnyElement { + fn shelf( + &self, + shelf: Shelf, + first: bool, + all: &[ThreadRow], + cx: &mut Context, + ) -> AnyElement { let Shelf { section, count, @@ -1342,16 +1504,16 @@ impl MainView { .into_any_element(), ]; let mut more = 0; - if let Some(shell) = self.shell() { + { let selected = self.selected_thread.as_deref(); - let mut rows = shelf_rows(&shell.rows, section, expanded, selected); + let mut rows = shelf_rows(all, section, expanded, selected); if section == Section::Settled && expanded { - let window = settled_window(&rows, &shell.rows, self.settled_shown, selected); + let window = settled_window(&rows, all, self.settled_shown, selected); rows = window.rows; more = window.more; } items.extend(rows.into_iter().map(|row| { - self.thread_item(&shell.rows[row], cx) + self.thread_item(&all[row], cx) .flex_shrink_0() .into_any_element() })); @@ -1458,30 +1620,201 @@ impl MainView { ), ) .when(has_projects, |row| { - row.child(header_icon_button( - "filter-projects", - Icons::ListFilter, - "Filter threads by project".into(), - cx, - )) - .child(header_icon_button( - "add-project", - Icons::FolderPlus, - "Add project".into(), - cx, - )) + row.child(self.scope_menu(cx)).child( + header_icon_button( + "add-project", + Icons::FolderPlus, + "Add project".into(), + true, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| this.add_project(cx))), + ) }) .child( header_icon_button( "new-thread", Icons::SquarePen, format!("New thread ({new_thread})").into(), + has_projects, cx, ) - .on_click(cx.listener(|this, _, _, cx| this.open_thread(None, cx))), + .when(has_projects, |button| { + button.on_click(cx.listener(|this, _, _, cx| this.new_thread(cx))) + }), ) } + /// The web's project scope combobox: "All projects" and one row per project; a shift click + /// adds a project to the scope instead of replacing it. + fn scope_menu(&self, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let search = self.scope_search.clone(); + let entries = self + .shell() + .map_or_else(Vec::new, |shell| shell.project_entries.clone()); + let scope = self.scope(); + let dark = cx.theme().is_dark(); + let scoped: Vec<(String, u32)> = entries + .iter() + .filter(|entry| scope.contains(&entry.key)) + .map(|entry| (entry.initials.clone(), entry.color_hex(dark))) + .collect(); + let label = { + let names: Vec<&str> = entries + .iter() + .filter(|entry| scope.contains(&entry.key)) + .map(|entry| entry.title.as_str()) + .collect(); + if names.is_empty() { + "Filter threads by project".to_owned() + } else { + format!("Filter threads by project: {}", names.join(", ")) + } + }; + Popover::new("project-scope") + .anchor(Anchor::TopLeft) + .open(self.scope_open) + .track_focus(&search.focus_handle(cx)) + .p_0() + .on_open_change({ + let (view, search) = (view.clone(), search.clone()); + move |open, window, cx| { + search.update(cx, |state, cx| state.set_value("", window, cx)); + view.update(cx, |this, cx| { + this.scope_open = *open; + cx.notify(); + }); + } + }) + .trigger(ScopeTrigger { + label: label.into(), + scoped, + open: false, + }) + .content(move |_, _, cx| { + let query = search.read(cx).value(); + let listed = projects::picker_entries(&entries, None, &query); + let theme = cx.theme(); + let (dark, muted) = (theme.is_dark(), theme.muted_foreground); + let (hover, selected, border) = (theme.list_hover, theme.list_active, theme.border); + let mono = theme.mono_font_family.clone(); + let row = |id: SharedString, active: bool, key: Option| { + let view = view.clone(); + h_flex() + .id(id) + .gap_2() + .px_2() + .py_1p5() + .items_center() + .rounded_md() + .cursor_pointer() + .when(active, |row| row.bg(selected)) + .hover(move |style| style.bg(hover)) + .on_click(move |event, _, cx| { + let additive = event.modifiers().shift; + view.update(cx, |this, cx| { + this.pick_scope(key.as_deref(), additive, cx) + }); + }) + }; + let all = "all projects".contains(query.trim().to_lowercase().as_str()); + let rows = listed.iter().enumerate().map(|(index, entry)| { + let color: Hsla = rgb(entry.color_hex(dark)).into(); + let active = scope.contains(&entry.key); + row( + format!("scope-{index}").into(), + active, + Some(entry.key.clone()), + ) + .child(monogram(&entry.initials, color, 20., &mono)) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .truncate() + .child(entry.title.clone()), + ) + .when(active, |row| { + row.child(Icon::new(IconName::Check).size_4().text_color(muted)) + }) + }); + v_flex() + .w(px(288.)) + .rounded_lg() + .overflow_hidden() + .child( + div().px_3().pt(px(10.)).child( + h_flex() + .pb(px(6.)) + .border_b_1() + .border_color(border.opacity(0.7)) + .child( + Input::new(&search) + .appearance(false) + .h(px(26.)) + .px_0() + .prefix( + Icon::new(IconName::Search) + .size_4() + .text_color(muted.opacity(0.55)), + ), + ), + ), + ) + .child( + v_flex() + .id("scope-list") + .max_h(px(PICKER_MAX_HEIGHT - PICKER_SEARCH_HEIGHT)) + .overflow_y_scroll() + .p_1() + .when(all, |list| { + list.child( + row("scope-all".into(), scope.is_empty(), None) + .child( + div() + .flex() + .flex_shrink_0() + .justify_center() + .size(px(20.)) + .child( + Icon::new(Icons::Folder) + .size_4() + .text_color(muted), + ), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .truncate() + .child("All projects"), + ) + .when(scope.is_empty(), |row| { + row.child( + Icon::new(IconName::Check) + .size_4() + .text_color(muted), + ) + }), + ) + }) + .children(rows) + .when(!all && listed.is_empty(), |list| { + list.child( + div() + .p_2() + .text_sm() + .text_color(muted) + .child("No matching projects."), + ) + }), + ) + }) + } + /// The main content, with the thread details card floating at its top right when it fits. fn main_area(&self, viewport_height: f32, cx: &mut Context) -> AnyElement { let drawer = self.terminal_drawer(viewport_height, cx); @@ -2651,6 +2984,7 @@ impl MainView { "attach", gpui_kit_assets::IconName::Paperclip, "Attach files".into(), + true, cx, )) .child( @@ -3075,9 +3409,78 @@ fn header_icon_button( id: &'static str, icon: gpui_kit_assets::IconName, label: gpui::SharedString, + enabled: bool, cx: &App, ) -> gpui::Stateful { - ghost_icon_button(id, Icon::new(icon).size_4(), label, true, cx) + ghost_icon_button(id, Icon::new(icon).size_4(), label, enabled, cx) +} + +/// A project's monogram badge: its initials on the project color at 14%. +fn monogram(initials: &str, color: Hsla, size: f32, font: &SharedString) -> gpui::Div { + div() + .flex() + .flex_shrink_0() + .items_center() + .justify_center() + .size(px(size)) + .rounded(px(size / 4.)) + .bg(color.opacity(0.14)) + .text_color(color) + .text_size(px(size / 2.)) + .font_weight(FontWeight::BOLD) + .font_family(font.clone()) + .child(initials.to_owned()) +} + +/// The scope menu's header button: the filter icon, or the badges of the scoped projects. +#[derive(IntoElement)] +struct ScopeTrigger { + label: SharedString, + scoped: Vec<(String, u32)>, + open: bool, +} + +impl Selectable for ScopeTrigger { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } + + fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + fn is_open(&self) -> bool { + self.open + } +} + +impl RenderOnce for ScopeTrigger { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + if self.scoped.is_empty() { + return ghost_icon_button( + "filter-projects", + Icon::new(Icons::ListFilter).size_4(), + self.label, + true, + cx, + ); + } + let mono = cx.theme().mono_font_family.clone(); + // Two badges at most, overlapped, as the web's stacked favicons. + let size = if self.scoped.len() > 1 { 14. } else { 16. }; + let badges = h_flex().children(self.scoped.iter().take(2).enumerate().map( + |(index, (initials, color))| { + monogram(initials, rgb(*color).into(), size, &mono) + .when(index > 0, |badge| badge.ml(px(-6.))) + }, + )); + ghost_icon_button("filter-projects", badges, self.label, true, cx) + } } /// The top bar's `icon-sm` buttons: `ghost`, or `media-navigation` on the stage art. @@ -3108,7 +3511,7 @@ fn nav_button( fn ghost_icon_button( id: &'static str, - icon: Icon, + icon: impl IntoElement, label: gpui::SharedString, enabled: bool, cx: &App, @@ -3121,7 +3524,7 @@ fn ghost_icon_button( fn icon_button( id: &'static str, - icon: Icon, + icon: impl IntoElement, label: gpui::SharedString, enabled: bool, idle: Hsla, @@ -4057,6 +4460,8 @@ impl Render for MainView { .on_action( cx.listener(|this, _: &OpenFavoriteEditor, _, cx| this.open_favorite_editor(cx)), ) + .on_action(cx.listener(|this, _: &NewThread, _, cx| this.new_thread(cx))) + .on_action(cx.listener(|this, _: &NewScratchThread, _, cx| this.new_scratch_thread(cx))) .on_action(cx.listener(|this, _: &GoBack, _, cx| this.go_back(cx))) .on_action(cx.listener(|this, _: &GoForward, _, cx| this.go_forward(cx))) .when(docked, |root| { diff --git a/apps/desktop-gpui/src/ui/right_panel.rs b/apps/desktop-gpui/src/ui/right_panel.rs index 9b82d6bfb283..9c5f56838a80 100644 --- a/apps/desktop-gpui/src/ui/right_panel.rs +++ b/apps/desktop-gpui/src/ui/right_panel.rs @@ -455,6 +455,7 @@ impl MainView { "toggle-right-panel", Icons::PanelRight, format!("Toggle right panel ({TOGGLE_SHORTCUT})").into(), + true, cx, ) .when(pressed, move |button| { @@ -531,8 +532,14 @@ impl MainView { .child(tab) .child(div().flex_1()) .child( - header_icon_button("refresh-diff", Icons::RefreshCw, "Refresh diff".into(), cx) - .on_click(cx.listener(|this, _, _, cx| this.refresh_diff(cx))), + header_icon_button( + "refresh-diff", + Icons::RefreshCw, + "Refresh diff".into(), + true, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| this.refresh_diff(cx))), ) .when(sheet, |strip| { strip.child( diff --git a/apps/desktop-gpui/src/wire/mod.rs b/apps/desktop-gpui/src/wire/mod.rs index 3fa68ec22ef8..6a15c0d56d00 100644 --- a/apps/desktop-gpui/src/wire/mod.rs +++ b/apps/desktop-gpui/src/wire/mod.rs @@ -9,6 +9,7 @@ pub use generated::{ OrchestrationSubscribeShellSuccess as ShellItem, OrchestrationSubscribeThreadPayload as SubscribeThreadPayload, ProjectsEntry as Project, ProjectsMutatePayload as ProjectMutation, + ProjectsMutatePayloadVariant0Type as ProjectMutationCreateType, ProjectsMutatePayloadVariant1Type as ProjectMutationType, ProjectsMutateSuccess, ThreadsEntry as Thread, }; From 0111247cfccf95b1baddc3b7086c57bf0f3975ea Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 16:54:22 -0500 Subject: [PATCH 40/82] refactor(desktop-gpui): share the ref picker popover between owners --- apps/desktop-gpui/src/details_card.rs | 223 +++++--------------------- apps/desktop-gpui/src/main.rs | 1 + apps/desktop-gpui/src/ref_picker.rs | 190 ++++++++++++++++++++++ 3 files changed, 228 insertions(+), 186 deletions(-) create mode 100644 apps/desktop-gpui/src/ref_picker.rs diff --git a/apps/desktop-gpui/src/details_card.rs b/apps/desktop-gpui/src/details_card.rs index 7a726e5d3a9b..202c065d4cc3 100644 --- a/apps/desktop-gpui/src/details_card.rs +++ b/apps/desktop-gpui/src/details_card.rs @@ -10,21 +10,22 @@ use crate::details::{self, ChangedFile, Density, Inputs, Show, VcsStatus}; use crate::git_actions::{ self, Action as GitAction, MenuId, Outcome, Progress, QuickKind, Terminology, }; -use crate::refs::{self, Page, Tag}; +use crate::ref_picker::RefMenu; +use crate::refs::Page; use crate::status::CallEvent; use crate::usage::{self, Limits, Tone}; use gpui::{ - Anchor, AnyElement, App, Context, Entity, EventEmitter, Focusable as _, FontWeight, Hsla, + Anchor, AnyElement, App, Context, Entity, EventEmitter, FontWeight, Hsla, InteractiveElement as _, IntoElement, ParentElement, Render, RenderOnce, ScrollHandle, StatefulInteractiveElement as _, Styled, WeakEntity, Window, div, img, prelude::*, px, relative, }; use gpui_component::{ - ActiveTheme, Disableable, Icon, IconName, Selectable, Sizable, WindowExt as _, + ActiveTheme, Disableable, Icon, Selectable, Sizable, WindowExt as _, button::{Button, ButtonVariants}, checkbox::Checkbox, h_flex, - input::{Input, InputEvent, InputState, Textarea, TextareaState}, + input::{InputEvent, InputState, Textarea, TextareaState}, popover::Popover, tooltip::Tooltip, v_flex, @@ -33,6 +34,7 @@ use gpui_kit_assets::IconName as Icons; use serde_json::{Value, json}; use std::{ collections::{HashMap, HashSet}, + rc::Rc, time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; @@ -864,7 +866,7 @@ impl DetailsCard { .ref_name .clone() .unwrap_or_else(|| "Select ref".to_owned()); - let snapshot = RefPicker { + let snapshot = RefMenu { query: self.refs_query.clone(), page: self.refs.clone(), loading: self.refs_loading, @@ -873,6 +875,35 @@ impl DetailsCard { project_cwd: props.project_cwd, search: self.search.clone(), scroll: self.ref_scroll.clone(), + can_create: true, + on_pick: Rc::new({ + let view = view.clone(); + move |name, current, cx| { + view.update(cx, |this, cx| { + if current { + this.ref_open = false; + cx.notify(); + } else { + this.switch_ref(name, cx); + } + }); + } + }), + on_create: Rc::new({ + let view = view.clone(); + move |name, cx| view.update(cx, |this, cx| this.create_ref(name, cx)) + }), + on_more: Rc::new({ + let view = view.clone(); + move |cx| { + view.update(cx, |this, cx| { + if !this.refs_loading { + this.list_refs(true); + cx.notify(); + } + }) + } + }), }; Popover::new("ref-picker") .anchor(Anchor::TopRight) @@ -892,7 +923,7 @@ impl DetailsCard { .child(div().flex_1().min_w_0().truncate().child(label)) .into_any_element() })) - .content(move |_, window, cx| snapshot.render(view.clone(), window, cx)) + .content(move |_, window, cx| snapshot.render(window, cx)) .into_any_element() } @@ -1190,186 +1221,6 @@ fn menu_warning(status: &VcsStatus) -> Option { (status.remote.behind > 0).then(|| "Behind upstream. Pull/rebase first.".to_owned()) } -/// What the ref picker's popover draws, snapshotted at each render of the card. -struct RefPicker { - query: String, - page: Page, - loading: bool, - error: Option, - cwd: String, - project_cwd: String, - search: Entity, - scroll: ScrollHandle, -} - -impl RefPicker { - fn render(&self, view: Entity, window: &mut Window, cx: &mut App) -> AnyElement { - let theme = cx.theme(); - let (muted, border, ring) = (theme.muted_foreground, theme.border, theme.ring); - let (hover, selected) = (theme.list_hover, theme.list_active); - let searching = self.search.focus_handle(cx).is_focused(window); - let sanitized = refs::sanitize_new_ref_name(&self.query); - let creatable = - !sanitized.is_empty() && !self.page.refs.iter().any(|r| r.name == sanitized); - let rows = self.page.refs.iter().enumerate().map(|(index, entry)| { - let tag = refs::tag(entry, &self.project_cwd); - let elsewhere = !entry.current - && entry - .worktree_path - .as_deref() - .is_some_and(|path| path != self.cwd); - let (view, name) = (view.clone(), entry.name.clone()); - let current = entry.current; - let label = match refs::split_for_middle_truncate(&entry.name, None) { - Some((head, tail)) => h_flex() - .flex_1() - .min_w_0() - .child(div().min_w_0().truncate().child(head)) - .child(div().flex_shrink_0().child(tail)) - .into_any_element(), - None => div() - .flex_1() - .min_w_0() - .truncate() - .child(entry.name.clone()) - .into_any_element(), - }; - h_flex() - .id(("ref", index)) - .gap_2() - .px_2() - .py_1p5() - .items_center() - .rounded_md() - .text_sm() - .when(current, |row| row.bg(selected)) - .when(elsewhere, |row| { - row.opacity(0.5) - .tooltip(tip("This ref is checked out in another worktree.")) - }) - .when(!elsewhere, |row| { - row.cursor_pointer() - .hover(move |style| style.bg(hover)) - .on_click(move |_, _, cx| { - view.update(cx, |this, cx| { - if current { - this.ref_open = false; - cx.notify(); - } else { - this.switch_ref(name.clone(), cx); - } - }); - }) - }) - .child(label) - .children(tag.map(|tag: Tag| { - div() - .flex_shrink_0() - .text_xs() - .text_color(muted) - .child(tag.label()) - })) - }); - let create = creatable.then(|| { - let (view, name) = (view.clone(), sanitized.clone()); - h_flex() - .id("ref-create") - .gap_2() - .px_2() - .py_1p5() - .items_center() - .rounded_md() - .text_sm() - .cursor_pointer() - .hover(move |style| style.bg(hover)) - .child(Icon::new(Icons::GitBranchPlus).size_4().text_color(muted)) - .child( - div() - .flex_1() - .min_w_0() - .truncate() - .child(format!("Create new ref \"{sanitized}\"")), - ) - .on_click(move |_, _, cx| { - view.update(cx, |this, cx| this.create_ref(name.clone(), cx)); - }) - }); - let more = (self.page.has_more && !self.loading).then(|| view.clone()); - let scroll = self.scroll.clone(); - let footer = self.page.status(self.loading); - let empty = self.page.refs.is_empty() && !self.loading && create.is_none(); - v_flex() - .w(px(288.)) - .rounded_lg() - .overflow_hidden() - .child( - div().px_3().pt(px(10.)).child( - h_flex() - .pb(px(6.)) - .border_b_1() - .border_color(if searching { ring } else { border.opacity(0.7) }) - .child( - Input::new(&self.search) - .appearance(false) - .h(px(26.)) - .px_0() - .prefix( - Icon::new(IconName::Search) - .size_4() - .text_color(muted.opacity(0.55)), - ), - ), - ), - ) - .child( - v_flex() - .id("ref-list") - .max_h(px(280.)) - .overflow_y_scroll() - .track_scroll(&scroll) - .on_scroll({ - let scroll = scroll.clone(); - move |_, _, cx| { - let Some(view) = &more else { return }; - let offset = scroll.offset().y; - let remaining = scroll.max_offset().y + offset; - if remaining < px(160.) { - view.update(cx, |this, cx| { - if !this.refs_loading { - this.list_refs(true); - cx.notify(); - } - }); - } - } - }) - .p_1() - .children(create) - .children(rows) - .when(empty, |list| { - list.child( - div().p_2().text_sm().text_color(muted).child( - self.error - .clone() - .unwrap_or_else(|| "No matching refs.".to_owned()), - ), - ) - }), - ) - .children(footer.map(|text| { - div() - .px_3() - .py_1p5() - .border_t_1() - .border_color(border) - .text_xs() - .text_color(muted) - .child(text) - })) - .into_any_element() - } -} - impl Render for DetailsCard { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let Some(props) = self.props.clone() else { diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 7d0247e6e949..3fc214c3993b 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -13,6 +13,7 @@ mod history; mod minimap; mod projects; mod providers; +mod ref_picker; mod refs; mod rpc; mod server; diff --git a/apps/desktop-gpui/src/ref_picker.rs b/apps/desktop-gpui/src/ref_picker.rs new file mode 100644 index 000000000000..f4d1088fe559 --- /dev/null +++ b/apps/desktop-gpui/src/ref_picker.rs @@ -0,0 +1,190 @@ +//! The branch ref picker's popover (`BranchPicker.tsx`): the search box, the ref rows with their +//! tags, the create row and the paging footer. Shared by the details card and the composer's +//! branch control; each owns its refs and decides what a click does. + +use crate::details_card::tip; +use crate::refs::{self, Page, Tag}; +use gpui::{ + AnyElement, App, Entity, Focusable as _, InteractiveElement as _, IntoElement, ParentElement, + ScrollHandle, StatefulInteractiveElement as _, Styled, Window, div, prelude::*, px, +}; +use gpui_component::{ + ActiveTheme, Icon, IconName, h_flex, + input::{Input, InputState}, + v_flex, +}; +use gpui_kit_assets::IconName as Icons; +use std::rc::Rc; + +/// What a ref picker's popover draws, snapshotted at each render of its owner. The owner decides +/// what picking, creating and paging do. +pub struct RefMenu { + pub query: String, + pub page: Page, + pub loading: bool, + pub error: Option, + pub cwd: String, + pub project_cwd: String, + pub search: Entity, + pub scroll: ScrollHandle, + /// Whether a typed name offers to create a ref. + pub can_create: bool, + /// A row was clicked: its name and whether it is the current ref. + pub on_pick: Rc, + pub on_create: Rc, + /// The list scrolled near its end with more refs to load. + pub on_more: Rc, +} + +impl RefMenu { + pub fn render(&self, window: &mut Window, cx: &mut App) -> AnyElement { + let theme = cx.theme(); + let (muted, border, ring) = (theme.muted_foreground, theme.border, theme.ring); + let (hover, selected) = (theme.list_hover, theme.list_active); + let searching = self.search.focus_handle(cx).is_focused(window); + let sanitized = refs::sanitize_new_ref_name(&self.query); + let creatable = self.can_create + && !sanitized.is_empty() + && !self.page.refs.iter().any(|r| r.name == sanitized); + let rows = self.page.refs.iter().enumerate().map(|(index, entry)| { + let tag = refs::tag(entry, &self.project_cwd); + let elsewhere = !entry.current + && entry + .worktree_path + .as_deref() + .is_some_and(|path| path != self.cwd); + let (on_pick, name) = (self.on_pick.clone(), entry.name.clone()); + let current = entry.current; + let label = match refs::split_for_middle_truncate(&entry.name, None) { + Some((head, tail)) => h_flex() + .flex_1() + .min_w_0() + .child(div().min_w_0().truncate().child(head)) + .child(div().flex_shrink_0().child(tail)) + .into_any_element(), + None => div() + .flex_1() + .min_w_0() + .truncate() + .child(entry.name.clone()) + .into_any_element(), + }; + h_flex() + .id(("ref", index)) + .gap_2() + .px_2() + .py_1p5() + .items_center() + .rounded_md() + .text_sm() + .when(current, |row| row.bg(selected)) + .when(elsewhere, |row| { + row.opacity(0.5) + .tooltip(tip("This ref is checked out in another worktree.")) + }) + .when(!elsewhere, |row| { + row.cursor_pointer() + .hover(move |style| style.bg(hover)) + .on_click(move |_, _, cx| on_pick(name.clone(), current, cx)) + }) + .child(label) + .children(tag.map(|tag: Tag| { + div() + .flex_shrink_0() + .text_xs() + .text_color(muted) + .child(tag.label()) + })) + }); + let create = creatable.then(|| { + let (on_create, name) = (self.on_create.clone(), sanitized.clone()); + h_flex() + .id("ref-create") + .gap_2() + .px_2() + .py_1p5() + .items_center() + .rounded_md() + .text_sm() + .cursor_pointer() + .hover(move |style| style.bg(hover)) + .child(Icon::new(Icons::GitBranchPlus).size_4().text_color(muted)) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .child(format!("Create new ref \"{sanitized}\"")), + ) + .on_click(move |_, _, cx| on_create(name.clone(), cx)) + }); + let more = (self.page.has_more && !self.loading).then(|| self.on_more.clone()); + let scroll = self.scroll.clone(); + let footer = self.page.status(self.loading); + let empty = self.page.refs.is_empty() && !self.loading && create.is_none(); + v_flex() + .w(px(288.)) + .rounded_lg() + .overflow_hidden() + .child( + div().px_3().pt(px(10.)).child( + h_flex() + .pb(px(6.)) + .border_b_1() + .border_color(if searching { ring } else { border.opacity(0.7) }) + .child( + Input::new(&self.search) + .appearance(false) + .h(px(26.)) + .px_0() + .prefix( + Icon::new(IconName::Search) + .size_4() + .text_color(muted.opacity(0.55)), + ), + ), + ), + ) + .child( + v_flex() + .id("ref-list") + .max_h(px(280.)) + .overflow_y_scroll() + .track_scroll(&scroll) + .on_scroll({ + let scroll = scroll.clone(); + move |_, _, cx| { + let Some(more) = &more else { return }; + let offset = scroll.offset().y; + let remaining = scroll.max_offset().y + offset; + if remaining < px(160.) { + more(cx); + } + } + }) + .p_1() + .children(create) + .children(rows) + .when(empty, |list| { + list.child( + div().p_2().text_sm().text_color(muted).child( + self.error + .clone() + .unwrap_or_else(|| "No matching refs.".to_owned()), + ), + ) + }), + ) + .children(footer.map(|text| { + div() + .px_3() + .py_1p5() + .border_t_1() + .border_color(border) + .text_xs() + .text_color(muted) + .child(text) + })) + .into_any_element() + } +} From 6494e96efd3548366e64a1bd0550092187532afc Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 16:54:22 -0500 Subject: [PATCH 41/82] feat(desktop-gpui): resolve model picks and rank picker search --- apps/desktop-gpui/src/providers.rs | 270 ++++++++++++++++++++++++++++- 1 file changed, 267 insertions(+), 3 deletions(-) diff --git a/apps/desktop-gpui/src/providers.rs b/apps/desktop-gpui/src/providers.rs index ae75a4049a52..7b1320c35a37 100644 --- a/apps/desktop-gpui/src/providers.rs +++ b/apps/desktop-gpui/src/providers.rs @@ -273,8 +273,39 @@ pub struct Mark { pub badge: Option, } +/// A model chosen in the picker for one composer: the instance it runs on and its slug. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Pick { + pub instance_id: String, + pub model: String, +} + +/// One model row of the picker. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PickerModel { + pub slug: String, + pub label: String, + /// The model's full name, for the tooltip. + pub title: String, + pub sub_provider: Option, +} + +/// One provider instance of the picker's sidebar, with its models. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PickerInstance { + pub instance_id: String, + pub mark: Mark, + pub name: String, + /// What the sidebar says when the instance cannot take a turn. + pub unavailable: Option, + pub models: Vec, + driver: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct Trigger { + pub instance_id: String, + pub slug: String, pub mark: Mark, pub model: String, pub effort: Option, @@ -358,12 +389,84 @@ impl Catalog { .unwrap_or_else(|| slug.to_owned()) } + /// The instances the picker lists, in the web's order: the ones enabled for `project`. + pub fn picker(&self, project: Option<&str>) -> Vec { + let entries = self.entries(project); + entries + .iter() + .filter(|entry| entry.enabled) + .map(|entry| { + let provider = entry.provider; + let mark = mark(&entries, entry); + PickerInstance { + instance_id: provider.instance_id.clone(), + name: mark.display_name.clone(), + unavailable: (!entry.available() || provider.status == "error") + .then(|| unavailable_note(&mark.display_name, provider)), + models: provider + .models + .iter() + .map(|model| PickerModel { + slug: model.slug.clone(), + label: trigger_model_name(model, &provider.driver), + title: display_model_name(&model.name, model.sub_provider.as_deref()), + sub_provider: model.sub_provider.clone(), + }) + .collect(), + driver: provider.driver.clone(), + mark, + } + }) + .collect() + } + + /// The choice of `slug` on `instance_id`, when that instance can take a turn and has the model. + pub fn pick(&self, project: Option<&str>, instance_id: &str, slug: &str) -> Option { + let entries = self.entries(project); + let entry = entries.iter().find(|entry| { + entry.provider.instance_id == instance_id + && entry.enabled + && entry.available() + && entry.provider.status != "error" + })?; + entry + .provider + .models + .iter() + .any(|model| model.slug == slug) + .then(|| Pick { + instance_id: instance_id.to_owned(), + model: slug.to_owned(), + }) + } + /// The trigger a fresh draft in `project` shows; `None` when no provider is selectable. pub fn trigger(&self, project: Option<&str>) -> Option { + self.trigger_for(project, None) + } + + /// The trigger of a draft whose user may have picked a model; a pick that no longer resolves + /// (the instance was disabled, or went away) falls back to the draft's default. + pub fn trigger_for(&self, project: Option<&str>, pick: Option<&Pick>) -> Option { let entries = self.entries(project); - let (entry, selection) = self.default_entry(&entries, project)?; + let picked = pick.and_then(|pick| { + let entry = entries.iter().find(|entry| { + entry.provider.instance_id == pick.instance_id && entry.enabled && entry.available() + })?; + let selection = Selection { + instance_id: Some(pick.instance_id.clone()), + provider: None, + model: pick.model.clone(), + options: Value::Null, + }; + Some((entry, selection)) + }); + let (entry, requested) = match &picked { + Some((entry, selection)) => (*entry, Some(selection)), + None => self.default_entry(&entries, project)?, + }; let provider = entry.provider; - let requested = selection + let requested = requested .filter(|selection| selection.instance() == Some(provider.instance_id.as_str())); let slug = match requested { Some(selection) => resolve_model(&provider.models, &selection.model) @@ -389,12 +492,14 @@ impl Catalog { let label = if slug.is_empty() { "Choose model".to_owned() } else { - slug + slug.clone() }; (label.clone(), label, None) } }; Some(Trigger { + instance_id: provider.instance_id.clone(), + slug, mark: mark(&entries, entry), model, effort, @@ -474,6 +579,95 @@ impl Catalog { } } +/// `describeUnavailableInstance` for an instance the picker cannot select. +fn unavailable_note(name: &str, provider: &Provider) -> String { + let kind = if provider.status == "error" { + "Unavailable" + } else { + "Not ready" + }; + format!("{name}: {kind}.") +} + +/// The picker's search: every token must match a field; exact names first, then the tightest +/// matches in list order (`modelPickerSearch.ts` without its fuzzy tier). +pub fn rank_models<'a>( + instances: &'a [PickerInstance], + query: &str, +) -> Vec<(&'a PickerInstance, &'a PickerModel)> { + let tokens: Vec = query.split_whitespace().map(str::to_lowercase).collect(); + let normalized = tokens.join(" "); + let mut ranked: Vec<(i32, usize, &PickerInstance, &PickerModel)> = Vec::new(); + let mut order = 0; + for instance in instances { + for model in &instance.models { + order += 1; + let fields = [ + model.title.to_lowercase(), + model.label.to_lowercase(), + model + .sub_provider + .clone() + .unwrap_or_default() + .to_lowercase(), + instance.driver.to_lowercase(), + instance.name.to_lowercase(), + ]; + if tokens.is_empty() { + ranked.push((0, order, instance, model)); + continue; + } + if fields[..2].iter().any(|field| *field == normalized) { + ranked.push((-1000, order, instance, model)); + continue; + } + let mut score = 0; + let mut matched = true; + for token in &tokens { + match fields + .iter() + .enumerate() + .filter_map(|(index, field)| { + token_score(field, token).map(|tier| index as i32 * 10 + tier) + }) + .min() + { + Some(best) => score += best, + None => { + matched = false; + break; + } + } + } + if matched { + ranked.push((score, order, instance, model)); + } + } + } + ranked.sort_by_key(|(score, order, ..)| (*score, *order)); + ranked + .into_iter() + .map(|(_, _, instance, model)| (instance, model)) + .collect() +} + +fn token_score(field: &str, token: &str) -> Option { + if field == token { + Some(0) + } else if field.starts_with(token) { + Some(2) + } else if field + .split(|ch: char| !ch.is_alphanumeric()) + .any(|word| word.starts_with(token)) + { + Some(4) + } else if field.contains(token) { + Some(6) + } else { + None + } +} + fn config_enabled(config: &InstanceConfig, driver: &str) -> bool { let flag = config.config["enabled"].as_bool(); if config.enabled == Some(false) || flag == Some(false) { @@ -1308,4 +1502,74 @@ mod tests { None ); } + + fn two_instances() -> Catalog { + catalog( + vec![ + provider( + "codex", + "codex", + "ready", + vec![ + model("gpt-5", "GPT-5", true), + model("gpt-5-mini", "GPT-5 Mini", false), + ], + ), + provider( + "claudeAgent", + "claudeAgent", + "ready", + vec![model("opus", "Claude Opus", true)], + ), + ], + json!({"providerInstances": {"codex": {"enabled": true}, "claudeAgent": {"enabled": true}}}), + ) + } + + #[test] + fn a_pick_replaces_the_default_trigger() { + let catalog = two_instances(); + let default = catalog.trigger(None).unwrap(); + assert_eq!(default.instance_id, "codex"); + assert_eq!(default.slug, "gpt-5"); + let pick = catalog.pick(None, "claudeAgent", "opus").unwrap(); + let picked = catalog.trigger_for(None, Some(&pick)).unwrap(); + assert_eq!( + (picked.instance_id.as_str(), picked.slug.as_str()), + ("claudeAgent", "opus") + ); + } + + #[test] + fn a_pick_that_no_longer_resolves_is_rejected_or_ignored() { + let catalog = two_instances(); + assert_eq!(catalog.pick(None, "claudeAgent", "nope"), None); + assert_eq!(catalog.pick(None, "ghost", "opus"), None); + let stale = Pick { + instance_id: "ghost".to_owned(), + model: "x".to_owned(), + }; + assert_eq!( + catalog.trigger_for(None, Some(&stale)), + catalog.trigger(None) + ); + } + + #[test] + fn search_ranks_exact_names_first_and_requires_every_token() { + let catalog = two_instances(); + let instances = catalog.picker(None); + assert_eq!(instances.len(), 2); + let slugs = |query: &str| -> Vec { + rank_models(&instances, query) + .iter() + .map(|(_, model)| model.slug.clone()) + .collect() + }; + assert_eq!(slugs(""), ["gpt-5", "gpt-5-mini", "opus"]); + assert_eq!(slugs("gpt-5"), ["gpt-5", "gpt-5-mini"]); + assert_eq!(slugs("mini gpt"), ["gpt-5-mini"]); + assert_eq!(slugs("claude"), ["opus"]); + assert!(slugs("zzz").is_empty()); + } } From 07e80d4b9c8362ac379820c20b6b767d70851ccf Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 16:57:23 -0500 Subject: [PATCH 42/82] feat(desktop-gpui): composer strip workspace and branch menus --- apps/desktop-gpui/src/branch_picker.rs | 316 +++++++++++++++++++++++++ apps/desktop-gpui/src/main.rs | 1 + apps/desktop-gpui/src/strip.rs | 26 ++ 3 files changed, 343 insertions(+) create mode 100644 apps/desktop-gpui/src/branch_picker.rs diff --git a/apps/desktop-gpui/src/branch_picker.rs b/apps/desktop-gpui/src/branch_picker.rs new file mode 100644 index 000000000000..ee168e4f3879 --- /dev/null +++ b/apps/desktop-gpui/src/branch_picker.rs @@ -0,0 +1,316 @@ +//! The composer strip's branch control (`BranchToolbarBranchSelector.tsx`): lists the project's +//! refs, and on a pick either checks the ref out (a draft in the current checkout) or records it +//! as the base of the new worktree. The owner applies what it emits. + +use crate::connection::{Action, ActionSender, Call}; +use crate::ref_picker::RefMenu; +use crate::refs::Page; +use crate::status::CallEvent; +use crate::strip::{Branch, EnvMode}; +use gpui::{ + AppContext as _, Context, Entity, EventEmitter, ScrollHandle, Window, +}; +use gpui_component::input::{InputEvent, InputState}; +use serde_json::{Value, json}; +use std::{collections::HashMap, rc::Rc}; + +/// Tokens of this picker's calls start here, apart from the details card's. +pub const TOKEN_BASE: u64 = 1 << 40; +const REF_PAGE: u64 = 100; + +/// What the owner learns from the picker. +pub enum Event { + /// A new worktree will start from this ref. + Base(Branch), + /// The checkout of `cwd` moved to another ref. + Switched, + Failed { title: &'static str, message: String }, +} + +impl EventEmitter for BranchPicker {} + +/// What picking a row does, by workspace mode. +#[derive(Debug, PartialEq, Eq)] +pub enum Pick { + /// The ref is already checked out. + Keep, + /// A new worktree starts from the ref. + Base, + Checkout, +} + +pub fn plan_pick(mode: EnvMode, current: bool) -> Pick { + match (mode, current) { + (EnvMode::Worktree, _) => Pick::Base, + (EnvMode::Local, true) => Pick::Keep, + (EnvMode::Local, false) => Pick::Checkout, + } +} + +pub fn switch_payload(cwd: &str, name: &str) -> Value { + json!({"cwd": cwd, "refName": name}) +} + +pub fn create_payload(cwd: &str, name: &str) -> Value { + json!({"cwd": cwd, "refName": name, "switchRef": true}) +} + +pub fn list_payload(cwd: &str, query: &str, cursor: Option) -> Value { + let mut payload = json!({"cwd": cwd, "limit": REF_PAGE}); + if !query.is_empty() { + payload["query"] = json!(query); + } + if let Some(cursor) = cursor { + payload["cursor"] = json!(cursor); + } + payload +} + +enum Pending { + Refs { query: String, append: bool }, + Switch, + Create, +} + +pub struct BranchPicker { + actions: ActionSender, + cwd: String, + mode: EnvMode, + open: bool, + search: Entity, + scroll: ScrollHandle, + refs: Page, + query: String, + loading: bool, + error: Option, + next_token: u64, + pending: HashMap, +} + +impl BranchPicker { + pub fn new(actions: ActionSender, window: &mut Window, cx: &mut Context) -> Self { + let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search branches...")); + cx.subscribe(&search, |this, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + this.query_changed(cx); + } + }) + .detach(); + Self { + actions, + cwd: String::new(), + mode: EnvMode::Local, + open: false, + search, + scroll: ScrollHandle::new(), + refs: Page::default(), + query: String::new(), + loading: false, + error: None, + next_token: TOKEN_BASE, + pending: HashMap::new(), + } + } + + pub fn is_open(&self) -> bool { + self.open + } + + /// Takes the draft's directory and workspace mode; a new directory drops the loaded refs. + pub fn sync(&mut self, cwd: &str, mode: EnvMode) { + if self.cwd != cwd { + self.cwd = cwd.to_owned(); + self.refs = Page::default(); + self.query.clear(); + self.error = None; + self.open = false; + } + self.mode = mode; + } + + pub fn set_open(&mut self, open: bool, window: &mut Window, cx: &mut Context) { + self.open = open; + if open { + self.query.clear(); + self.refs = Page::default(); + self.search + .update(cx, |state, cx| state.set_value("", window, cx)); + self.list_refs(false); + } + cx.notify(); + } + + /// What the popover draws; `create` only makes sense when the checkout moves. + pub fn menu(&self, this: &Entity) -> RefMenu { + let (on_pick, on_create, on_more) = (this.clone(), this.clone(), this.clone()); + RefMenu { + query: self.query.clone(), + page: self.refs.clone(), + loading: self.loading, + error: self.error.clone(), + cwd: self.cwd.clone(), + project_cwd: self.cwd.clone(), + search: self.search.clone(), + scroll: self.scroll.clone(), + can_create: self.mode == EnvMode::Local, + on_pick: Rc::new(move |name, current, cx| { + on_pick.update(cx, |this, cx| this.pick(name, current, cx)); + }), + on_create: Rc::new(move |name, cx| { + on_create.update(cx, |this, cx| this.create(name, cx)); + }), + on_more: Rc::new(move |cx| { + on_more.update(cx, |this, cx| { + if !this.loading { + this.list_refs(true); + cx.notify(); + } + }); + }), + } + } + + fn call(&mut self, call: Call, payload: Value, pending: Pending) { + self.next_token += 1; + let token = self.next_token; + self.pending.insert(token, pending); + let action = Action::Call { + token, + call, + payload, + }; + if self.actions.try_send(action).is_err() { + self.pending.remove(&token); + eprintln!("[branch] could not send the request"); + } + } + + fn list_refs(&mut self, append: bool) { + let cursor = if append { + let Some(cursor) = self.refs.next_cursor else { + return; + }; + Some(cursor) + } else { + None + }; + self.loading = true; + self.error = None; + let query = self.query.clone(); + let payload = list_payload(&self.cwd, &query, cursor); + self.call(Call::ListRefs, payload, Pending::Refs { query, append }); + } + + fn query_changed(&mut self, cx: &mut Context) { + let query = self.search.read(cx).value().to_string(); + if query == self.query { + return; + } + self.query = query; + self.refs = Page::default(); + self.list_refs(false); + cx.notify(); + } + + fn pick(&mut self, name: String, current: bool, cx: &mut Context) { + self.open = false; + match plan_pick(self.mode, current) { + Pick::Keep => {} + Pick::Base => { + let is_remote = self + .refs + .refs + .iter() + .find(|entry| entry.name == name) + .map(|entry| entry.is_remote); + cx.emit(Event::Base(Branch { + name: Some(name), + is_remote, + })); + } + Pick::Checkout => { + let payload = switch_payload(&self.cwd, &name); + self.call(Call::SwitchRef, payload, Pending::Switch); + } + } + cx.notify(); + } + + fn create(&mut self, name: String, cx: &mut Context) { + self.open = false; + let payload = create_payload(&self.cwd, &name); + self.call(Call::CreateRef, payload, Pending::Create); + cx.notify(); + } + + pub fn on_call(&mut self, token: u64, event: CallEvent, cx: &mut Context) { + match event { + CallEvent::Progress(_) => return, + CallEvent::Done(value) => match self.pending.remove(&token) { + Some(Pending::Refs { query, append }) if query == self.query => { + let page = Page::decode(&value); + if append { + self.refs.refs.extend(page.refs); + self.refs.total = page.total; + self.refs.has_more = page.has_more; + self.refs.next_cursor = page.next_cursor; + } else { + self.refs = page; + } + self.loading = false; + } + Some(Pending::Switch | Pending::Create) => cx.emit(Event::Switched), + _ => {} + }, + CallEvent::Failed(message) => match self.pending.remove(&token) { + Some(Pending::Refs { query, .. }) if query == self.query => { + self.loading = false; + self.error = Some(message); + } + Some(Pending::Switch) => cx.emit(Event::Failed { + title: "Failed to switch ref.", + message, + }), + Some(Pending::Create) => cx.emit(Event::Failed { + title: "Failed to create and switch ref.", + message, + }), + _ => {} + }, + } + cx.notify(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_worktree_draft_only_records_the_base() { + assert_eq!(plan_pick(EnvMode::Worktree, false), Pick::Base); + assert_eq!(plan_pick(EnvMode::Worktree, true), Pick::Base); + assert_eq!(plan_pick(EnvMode::Local, true), Pick::Keep); + assert_eq!(plan_pick(EnvMode::Local, false), Pick::Checkout); + } + + #[test] + fn payloads_match_the_vcs_requests() { + assert_eq!( + switch_payload("/p", "dev"), + json!({"cwd": "/p", "refName": "dev"}) + ); + assert_eq!( + create_payload("/p", "feat"), + json!({"cwd": "/p", "refName": "feat", "switchRef": true}) + ); + assert_eq!( + list_payload("/p", "", None), + json!({"cwd": "/p", "limit": 100}) + ); + assert_eq!( + list_payload("/p", "de", Some(7)), + json!({"cwd": "/p", "limit": 100, "query": "de", "cursor": 7}) + ); + } +} diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 3fc214c3993b..79ade0bae7d8 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -1,4 +1,5 @@ mod assets; +mod branch_picker; mod auth; mod catalog; mod config; diff --git a/apps/desktop-gpui/src/strip.rs b/apps/desktop-gpui/src/strip.rs index 878803617df6..b916c8ff9ffc 100644 --- a/apps/desktop-gpui/src/strip.rs +++ b/apps/desktop-gpui/src/strip.rs @@ -22,6 +22,9 @@ pub enum EnvMode { } impl EnvMode { + /// The workspace menu's rows, in the web's order. + pub const ALL: [Self; 2] = [Self::Local, Self::Worktree]; + fn parse(text: &str) -> Option { match text { "local" => Some(Self::Local), @@ -156,6 +159,19 @@ impl Branch { } } +/// The ref the branch control names: a new worktree starts from the ref picked as its base, else +/// from the checked-out one; the current checkout always shows the checked-out ref. +pub fn shown_branch<'a>( + mode: EnvMode, + current: Option<&'a Branch>, + base: Option<&'a Branch>, +) -> Option<&'a Branch> { + match mode { + EnvMode::Worktree => base.or(current), + EnvMode::Local => current, + } +} + /// `resolveEnvironmentOptionLabel` for the primary environment: a generic name reads as "This device". pub fn environment_label(label: &str) -> String { let label = label.trim(); @@ -202,6 +218,16 @@ mod tests { ); } + #[test] + fn a_picked_base_only_applies_to_a_new_worktree() { + let current = branch(Some("main"), Some(false)); + let base = branch(Some("dev"), Some(false)); + let shown = |mode, base| shown_branch(mode, Some(¤t), base).cloned(); + assert_eq!(shown(EnvMode::Worktree, Some(&base)), Some(base.clone())); + assert_eq!(shown(EnvMode::Worktree, None), Some(current.clone())); + assert_eq!(shown(EnvMode::Local, Some(&base)), Some(current)); + } + #[test] fn decode_reads_the_checked_out_ref() { let result = json!({"refs": [ From ee0046dbf66f6167237bcdb9ca7668a1a5575197 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 16:57:28 -0500 Subject: [PATCH 43/82] feat(desktop-gpui): wire composer strip menus into the view --- apps/desktop-gpui/src/ui.rs | 313 ++++++++++++++++++++++++++++++++---- 1 file changed, 281 insertions(+), 32 deletions(-) diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index df5c567b5e78..dfdf2ac6b407 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -1,11 +1,12 @@ use crate::assets::{MESSAGE_SQUARE_DASHED, STAGE_DEV, STAGE_DEV_BUTTON, provider_icon}; +use crate::branch_picker::{self, BranchPicker}; use crate::connection::{Action, ActionSender, new_project_id}; use crate::details::{self, VcsStatus}; use crate::details_card::{DetailsCard, OpenDiff, Props}; use crate::history::History; use crate::minimap; use crate::projects::{self, EnvironmentProjects, MachineKind, NewProject, ProjectEntry}; -use crate::providers::{Catalog, Mark, Trigger}; +use crate::providers::{Catalog, Mark, Pick, Trigger}; use crate::slow_requests; use crate::status::{ LastError, PrBadge, PrState, Section, ShellView, Status, ThreadRow, TopStatus, @@ -194,6 +195,18 @@ pub struct MainView { /// Hover styles cannot change layout, so the send button's hover growth is view state. send_hovered: bool, permissions_open: bool, + workspace_open: bool, + /// The workspace mode the user picked for a draft, by project id. + env_modes: HashMap, + /// The ref a new worktree will start from, by project id. + draft_bases: HashMap, + branch_picker: Entity, + model_open: bool, + /// The provider instance the model picker's sidebar shows. + model_instance: Option, + model_search: Entity, + /// The model picked in each composer (an open thread or a project's draft), by details key. + model_picks: HashMap, selected_thread: Option, transcript: Option, transcript_list: ListState, @@ -298,6 +311,18 @@ impl MainView { selected: RuntimeMode::FullAccess, preview: None, }); + let model_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search models...")); + cx.subscribe(&model_search, |_, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }) + .detach(); + let branch_picker = cx.new(|cx| BranchPicker::new(actions.clone(), window, cx)); + cx.subscribe(&branch_picker, |this, _, event: &branch_picker::Event, cx| { + this.on_branch_event(event, cx) + }) + .detach(); let card = cx.new(|cx| DetailsCard::new(actions.clone(), window, cx)); cx.subscribe(&card, |this, _, _: &OpenDiff, cx| this.open_diff(cx)) .detach(); @@ -317,6 +342,14 @@ impl MainView { reserved_composer_height: Cell::new(0.), send_hovered: false, permissions_open: false, + workspace_open: false, + env_modes: HashMap::new(), + draft_bases: HashMap::new(), + branch_picker, + model_open: false, + model_instance: None, + model_search, + model_picks: HashMap::new(), selected_thread: None, transcript: None, // Overdraw keeps a screenful of rows measured beyond each edge. @@ -445,6 +478,11 @@ impl MainView { return cx.notify(); } if let Status::Call { token, event } = status { + if token >= branch_picker::TOKEN_BASE { + self.branch_picker + .update(cx, |picker, cx| picker.on_call(token, event, cx)); + return; + } self.card .update(cx, |card, cx| card.on_call(token, event, cx)); return; @@ -2862,7 +2900,11 @@ impl MainView { } /// The web's `ComposerSurface.ContextStrip` under the composer; none for the scratch project. - fn context_strip(&self, project: &ProjectEntry, cx: &App) -> Option { + fn context_strip( + &self, + project: &ProjectEntry, + cx: &mut Context, + ) -> Option { let Status::Connected { label, machine, .. } = &self.status else { return None; }; @@ -2874,19 +2916,27 @@ impl MainView { |c| c.defaults(Some(&project.id)), ); let environment = strip::environment_label(label); - let branch = self - .branches - .get(&project.root) - .and_then(Option::as_ref) - .map(|branch| { - let label = branch.label(defaults.env_mode, defaults.start_from_origin); - // The branch is the one label that gives way in a narrow window. - strip_control("branch", Icons::GitBranch, label.clone(), label, cx) - .flex_shrink(1.) - .min_w_0() - }); - let is_repo = branch.is_some(); - let workspace = defaults.env_mode; + let workspace = self + .env_modes + .get(&project.id) + .copied() + .unwrap_or(defaults.env_mode); + let current = self.branches.get(&project.root).and_then(Option::as_ref); + let is_repo = current.is_some(); + let shown = strip::shown_branch(workspace, current, self.draft_bases.get(&project.id)); + let branch_label = + shown.map(|branch| branch.label(workspace, defaults.start_from_origin)); + self.branch_picker + .update(cx, |picker, _| picker.sync(&project.root, workspace)); + let branch = match branch_label.filter(|_| is_repo) { + Some(label) => Some(self.branch_popover(label, cx)), + None => None, + }; + let workspace_control = if is_repo { + Some(self.workspace_popover(&project.id, workspace, cx)) + } else { + None + }; let runtime_mode = self.runtime_mode.unwrap_or(defaults.runtime_mode); Some( h_flex() @@ -2895,29 +2945,125 @@ impl MainView { .gap_1() .px_2() .pt_2() - .child(strip_control( - "host", - machine_icon(*machine), - environment.clone(), - strip::tooltip_text(&environment, Some(HOST_SHORTCUT)), - cx, - )) - .children(is_repo.then(|| strip_separator(cx))) - .children(is_repo.then(|| { + .child( + // One environment is the only one a draft can run on, so the host is a label. strip_control( - "workspace", - workspace_icon(workspace), - workspace.label().to_owned(), - strip::tooltip_text(workspace.label(), Some(WORKSPACE_SHORTCUT)), + "host", + machine_icon(*machine), + environment.clone(), + strip::tooltip_text(&environment, Some(HOST_SHORTCUT)), + false, cx, - ) - })) + ), + ) + .children(is_repo.then(|| strip_separator(cx))) + .children(workspace_control) .child(div().flex_1()) .children(branch) .child(self.permissions_popover(runtime_mode, cx)), ) } + /// The web's `BranchToolbarEnvModeSelector`: a menu of the two workspace modes. + fn workspace_popover( + &self, + project_id: &str, + mode: EnvMode, + cx: &mut Context, + ) -> AnyElement { + let view = cx.entity(); + let project_id = project_id.to_owned(); + Popover::new("workspace") + .anchor(Anchor::BottomLeft) + .open(self.workspace_open) + .p_0() + .on_open_change({ + let view = view.clone(); + move |open, _, cx| { + view.update(cx, |this, cx| { + this.workspace_open = *open; + cx.notify(); + }); + } + }) + .trigger(StripTrigger { + id: "workspace", + icon: workspace_icon(mode), + label: mode.label().to_owned(), + tip: strip::tooltip_text(mode.label(), Some(WORKSPACE_SHORTCUT)), + shrink: false, + open: false, + }) + .content(move |_, _, cx| { + let theme = cx.theme(); + let (muted, hover, selected) = + (theme.muted_foreground, theme.list_hover, theme.list_active); + let rows = EnvMode::ALL.into_iter().map(|choice| { + let (view, project_id) = (view.clone(), project_id.clone()); + h_flex() + .id(("workspace-mode", choice as usize)) + .gap(px(6.)) + .px_2() + .py_1p5() + .items_center() + .rounded_md() + .text_sm() + .cursor_pointer() + .when(choice == mode, |row| row.bg(selected)) + .hover(move |style| style.bg(hover)) + .child(Icon::new(workspace_icon(choice)).size(px(12.))) + .child(div().flex_1().child(choice.label())) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| { + this.env_modes.insert(project_id.clone(), choice); + this.workspace_open = false; + cx.notify(); + }); + }) + }); + v_flex() + .w(px(224.)) + .p_1() + .child( + div() + .px_2() + .py_1() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(muted) + .child("Workspace"), + ) + .children(rows) + }) + .into_any_element() + } + + /// The web's `BranchToolbarBranchSelector` in the composer strip. + fn branch_popover(&self, label: String, cx: &mut Context) -> AnyElement { + let picker = self.branch_picker.clone(); + let menu = picker.read(cx).menu(&picker); + Popover::new("branch") + .anchor(Anchor::BottomRight) + .open(picker.read(cx).is_open()) + .p_0() + .on_open_change({ + let picker = picker.clone(); + move |open, window, cx| { + picker.update(cx, |picker, cx| picker.set_open(*open, window, cx)); + } + }) + .trigger(StripTrigger { + id: "branch", + icon: Icons::GitBranch, + tip: label.clone(), + label, + shrink: true, + open: false, + }) + .content(move |_, window, cx| menu.render(window, cx)) + .into_any_element() + } + /// The web's `ComposerContextPermissions` menu: opens above the chip, aligned to its end. fn permissions_popover(&self, mode: RuntimeMode, cx: &App) -> impl IntoElement { let view = self.permissions.read(cx).view.clone(); @@ -2942,6 +3088,62 @@ impl MainView { }) } + fn on_branch_event(&mut self, event: &branch_picker::Event, cx: &mut Context) { + match event { + branch_picker::Event::Base(branch) => { + if let Some(id) = self.draft_project().map(|entry| entry.id.clone()) { + self.draft_bases.insert(id, branch.clone()); + } + } + branch_picker::Event::Switched => { + if let Some(root) = self.draft_project().map(|entry| entry.root.clone()) { + self.branches_requested.remove(&root); + self.request_branch(); + } + } + branch_picker::Event::Failed { title, message } => { + let toast = Toast::new(*title) + .kind(ToastKind::Error) + .description(message.clone()); + self.toasts.update(cx, |toasts, cx| { + toasts.add(toast, cx); + }); + } + } + cx.notify(); + } + + /// The project the composer's draft belongs to. + fn draft_project(&self) -> Option<&ProjectEntry> { + active_project(self.selected_project.as_deref(), self.shell()?) + } + + /// The model picked for the composer in view, when it still resolves. + fn model_pick(&self, key: Option<&str>) -> Option<&Pick> { + self.model_picks.get(key?) + } + + fn pick_model(&mut self, instance_id: &str, slug: &str, cx: &mut Context) { + let project = self.selected_project.clone().filter(|id| !id.is_empty()); + let picked = self + .providers + .as_ref() + .and_then(|catalog| catalog.pick(project.as_deref(), instance_id, slug)); + self.model_open = false; + match (picked, self.details_key()) { + (Some(pick), Some(key)) => { + self.model_picks.insert(key, pick); + } + _ => { + let toast = Toast::new("That model is no longer available.").kind(ToastKind::Error); + self.toasts.update(cx, |toasts, cx| { + toasts.add(toast, cx); + }); + } + } + cx.notify(); + } + fn open_permissions(&mut self, cx: &mut Context) { let strip = self.shell().is_some_and(|shell| { active_project(self.selected_project.as_deref(), shell).is_some_and(|e| !e.scratch) @@ -3015,6 +3217,7 @@ fn strip_control( icon: Icons, label: String, tip: String, + interactive: bool, cx: &App, ) -> gpui::Stateful { let theme = cx.theme(); @@ -3030,8 +3233,11 @@ fn strip_control( .rounded(px(8.)) .text_xs() .text_color(muted) - .cursor_pointer() - .hover(move |style| style.bg(foreground.opacity(0.06)).text_color(foreground)) + .when(interactive, |control| { + control + .cursor_pointer() + .hover(move |style| style.bg(foreground.opacity(0.06)).text_color(foreground)) + }) .tooltip(tooltip(tip)) .child(Icon::new(icon).size(px(12.))) .child( @@ -3045,6 +3251,48 @@ fn strip_control( ) } +/// A strip control as a popover trigger. +#[derive(IntoElement)] +struct StripTrigger { + id: &'static str, + icon: Icons, + label: String, + tip: String, + shrink: bool, + open: bool, +} + +impl Selectable for StripTrigger { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } + + fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + fn is_open(&self) -> bool { + self.open + } +} + +impl RenderOnce for StripTrigger { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let tip = if self.open { String::new() } else { self.tip }; + let foreground = cx.theme().foreground; + strip_control(self.id, self.icon, self.label, tip, true, cx) + .when(self.shrink, |control| control.flex_shrink(1.).min_w_0()) + .when(self.open, move |control| { + control.bg(foreground.opacity(0.06)).text_color(foreground) + }) + } +} + /// The permissions chip as a popover trigger. #[derive(IntoElement)] struct PermissionsTrigger { @@ -3083,6 +3331,7 @@ impl RenderOnce for PermissionsTrigger { runtime_icon(self.mode), self.mode.label().to_owned(), tip, + true, cx, ); let foreground = cx.theme().foreground; From c8e907c0b6619f5093120239710aea2667d80d8d Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 16:58:41 -0500 Subject: [PATCH 44/82] feat(desktop-gpui): composer model picker popup --- apps/desktop-gpui/src/branch_picker.rs | 9 +- apps/desktop-gpui/src/main.rs | 2 +- apps/desktop-gpui/src/providers.rs | 3 +- apps/desktop-gpui/src/ref_picker.rs | 7 +- apps/desktop-gpui/src/ui.rs | 269 ++++++++++++++++++++++--- 5 files changed, 249 insertions(+), 41 deletions(-) diff --git a/apps/desktop-gpui/src/branch_picker.rs b/apps/desktop-gpui/src/branch_picker.rs index ee168e4f3879..a82b2bee8ceb 100644 --- a/apps/desktop-gpui/src/branch_picker.rs +++ b/apps/desktop-gpui/src/branch_picker.rs @@ -7,9 +7,7 @@ use crate::ref_picker::RefMenu; use crate::refs::Page; use crate::status::CallEvent; use crate::strip::{Branch, EnvMode}; -use gpui::{ - AppContext as _, Context, Entity, EventEmitter, ScrollHandle, Window, -}; +use gpui::{AppContext as _, Context, Entity, EventEmitter, ScrollHandle, Window}; use gpui_component::input::{InputEvent, InputState}; use serde_json::{Value, json}; use std::{collections::HashMap, rc::Rc}; @@ -24,7 +22,10 @@ pub enum Event { Base(Branch), /// The checkout of `cwd` moved to another ref. Switched, - Failed { title: &'static str, message: String }, + Failed { + title: &'static str, + message: String, + }, } impl EventEmitter for BranchPicker {} diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 79ade0bae7d8..511ea11fc8a1 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -1,6 +1,6 @@ mod assets; -mod branch_picker; mod auth; +mod branch_picker; mod catalog; mod config; mod connection; diff --git a/apps/desktop-gpui/src/providers.rs b/apps/desktop-gpui/src/providers.rs index 7b1320c35a37..f980c7d88fad 100644 --- a/apps/desktop-gpui/src/providers.rs +++ b/apps/desktop-gpui/src/providers.rs @@ -441,6 +441,7 @@ impl Catalog { } /// The trigger a fresh draft in `project` shows; `None` when no provider is selectable. + #[cfg(test)] pub fn trigger(&self, project: Option<&str>) -> Option { self.trigger_for(project, None) } @@ -617,7 +618,7 @@ pub fn rank_models<'a>( ranked.push((0, order, instance, model)); continue; } - if fields[..2].iter().any(|field| *field == normalized) { + if fields[..2].contains(&normalized) { ranked.push((-1000, order, instance, model)); continue; } diff --git a/apps/desktop-gpui/src/ref_picker.rs b/apps/desktop-gpui/src/ref_picker.rs index f4d1088fe559..f4f3c2fa8854 100644 --- a/apps/desktop-gpui/src/ref_picker.rs +++ b/apps/desktop-gpui/src/ref_picker.rs @@ -16,6 +16,9 @@ use gpui_component::{ use gpui_kit_assets::IconName as Icons; use std::rc::Rc; +type PickHandler = Rc; +type CreateHandler = Rc; + /// What a ref picker's popover draws, snapshotted at each render of its owner. The owner decides /// what picking, creating and paging do. pub struct RefMenu { @@ -30,8 +33,8 @@ pub struct RefMenu { /// Whether a typed name offers to create a ref. pub can_create: bool, /// A row was clicked: its name and whether it is the current ref. - pub on_pick: Rc, - pub on_create: Rc, + pub on_pick: PickHandler, + pub on_create: CreateHandler, /// The list scrolled near its end with more refs to load. pub on_more: Rc, } diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index dfdf2ac6b407..e515f57fe066 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -6,7 +6,7 @@ use crate::details_card::{DetailsCard, OpenDiff, Props}; use crate::history::History; use crate::minimap; use crate::projects::{self, EnvironmentProjects, MachineKind, NewProject, ProjectEntry}; -use crate::providers::{Catalog, Mark, Pick, Trigger}; +use crate::providers::{self, Catalog, Mark, Pick, Trigger}; use crate::slow_requests; use crate::status::{ LastError, PrBadge, PrState, Section, ShellView, Status, ThreadRow, TopStatus, @@ -319,9 +319,10 @@ impl MainView { }) .detach(); let branch_picker = cx.new(|cx| BranchPicker::new(actions.clone(), window, cx)); - cx.subscribe(&branch_picker, |this, _, event: &branch_picker::Event, cx| { - this.on_branch_event(event, cx) - }) + cx.subscribe( + &branch_picker, + |this, _, event: &branch_picker::Event, cx| this.on_branch_event(event, cx), + ) .detach(); let card = cx.new(|cx| DetailsCard::new(actions.clone(), window, cx)); cx.subscribe(&card, |this, _, _: &OpenDiff, cx| this.open_diff(cx)) @@ -2924,14 +2925,12 @@ impl MainView { let current = self.branches.get(&project.root).and_then(Option::as_ref); let is_repo = current.is_some(); let shown = strip::shown_branch(workspace, current, self.draft_bases.get(&project.id)); - let branch_label = - shown.map(|branch| branch.label(workspace, defaults.start_from_origin)); + let branch_label = shown.map(|branch| branch.label(workspace, defaults.start_from_origin)); self.branch_picker .update(cx, |picker, _| picker.sync(&project.root, workspace)); - let branch = match branch_label.filter(|_| is_repo) { - Some(label) => Some(self.branch_popover(label, cx)), - None => None, - }; + let branch = branch_label + .filter(|_| is_repo) + .map(|label| self.branch_popover(label, cx)); let workspace_control = if is_repo { Some(self.workspace_popover(&project.id, workspace, cx)) } else { @@ -3152,11 +3151,184 @@ impl MainView { cx.notify(); } + /// The web's `ProviderModelPicker`: instances in a sidebar, a search, and the model list. + fn model_popover(&self, cx: &mut Context) -> Option { + let catalog = self.providers.as_ref()?; + let project = self.selected_project.clone().filter(|id| !id.is_empty()); + let key = self.details_key(); + let trigger = catalog.trigger_for(project.as_deref(), self.model_pick(key.as_deref()))?; + let instances = catalog.picker(project.as_deref()); + let (view, search) = (cx.entity(), self.model_search.clone()); + let shown = self + .model_instance + .clone() + .unwrap_or_else(|| trigger.instance_id.clone()); + let current = (trigger.instance_id.clone(), trigger.slug.clone()); + Some( + Popover::new("model-picker") + .anchor(Anchor::BottomRight) + .open(self.model_open) + .track_focus(&search.focus_handle(cx)) + .p_0() + .on_open_change({ + let (view, search) = (view.clone(), search.clone()); + move |open, window, cx| { + search.update(cx, |state, cx| state.set_value("", window, cx)); + view.update(cx, |this, cx| { + this.model_open = *open; + this.model_instance = None; + cx.notify(); + }); + } + }) + .trigger(ModelTrigger { + trigger, + open: self.model_open, + }) + .content(move |_, _, cx| { + let query = search.read(cx).value(); + let theme = cx.theme(); + let (muted, hover, selected) = + (theme.muted_foreground, theme.list_hover, theme.list_active); + let (border, foreground) = (theme.border, theme.foreground); + let searching = !query.trim().is_empty(); + let listed: Vec<_> = if searching { + providers::rank_models(&instances, &query) + } else { + instances + .iter() + .filter(|instance| instance.instance_id == shown) + .flat_map(|instance| instance.models.iter().map(move |m| (instance, m))) + .collect() + }; + let sidebar = instances.iter().enumerate().map(|(index, instance)| { + let (view, id) = (view.clone(), instance.instance_id.clone()); + let usable = instance.unavailable.is_none(); + let active = !searching && instance.instance_id == shown; + let note = instance.unavailable.clone(); + div() + .id(("model-instance", index)) + .flex() + .items_center() + .justify_center() + .size(px(32.)) + .rounded_md() + .when(active, |item| item.bg(selected)) + .when(usable, |item| { + item.cursor_pointer().hover(move |style| style.bg(hover)) + }) + .when(!usable, |item| item.opacity(0.45)) + .tooltip(tooltip(note.unwrap_or_else(|| instance.name.clone()))) + .child(provider_mark(&instance.mark, 1., cx)) + .when(usable, |item| { + item.on_click(move |_, _, cx| { + view.update(cx, |this, cx| { + this.model_instance = Some(id.clone()); + cx.notify(); + }); + }) + }) + }); + let rows = listed.iter().enumerate().map(|(index, (instance, model))| { + let (view, id, slug) = ( + view.clone(), + instance.instance_id.clone(), + model.slug.clone(), + ); + let chosen = (id.as_str(), slug.as_str()) + == (current.0.as_str(), current.1.as_str()); + let usable = instance.unavailable.is_none(); + h_flex() + .id(("model-row", index)) + .gap_2() + .px_2() + .py_1p5() + .items_center() + .rounded_md() + .text_sm() + .when(usable, |row| { + row.cursor_pointer().hover(move |style| style.bg(hover)) + }) + .when(!usable, |row| row.opacity(0.45)) + .tooltip(tooltip(model.title.clone())) + .when(searching, |row| { + row.child(provider_mark(&instance.mark, 1., cx)) + }) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .child(model.label.clone()), + ) + .when(chosen, |row| { + row.child(Icon::new(IconName::Check).small().text_color(foreground)) + }) + .when(usable, |row| { + row.on_click(move |_, _, cx| { + view.update(cx, |this, cx| this.pick_model(&id, &slug, cx)); + }) + }) + }); + h_flex() + .w(px(448.)) + .h(px(320.)) + .child( + v_flex() + .gap_1() + .p_1() + .border_r_1() + .border_color(border.opacity(0.7)) + .children(sidebar), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .child( + div().px_3().pt(px(10.)).child( + h_flex() + .pb(px(6.)) + .border_b_1() + .border_color(border.opacity(0.7)) + .child( + Input::new(&search) + .appearance(false) + .h(px(26.)) + .px_0() + .prefix( + Icon::new(IconName::Search) + .size_4() + .text_color(muted.opacity(0.55)), + ), + ), + ), + ) + .child( + v_flex() + .id("model-list") + .flex_1() + .overflow_y_scroll() + .p_1() + .children(rows) + .when(listed.is_empty(), |list| { + list.child( + div() + .p_2() + .text_sm() + .text_color(muted) + .child("No matching models."), + ) + }), + ), + ) + }) + .into_any_element(), + ) + } + fn composer_box(&self, cx: &mut Context) -> impl IntoElement { - let trigger = self.providers.as_ref().and_then(|catalog| { - let project = self.selected_project.as_deref(); - catalog.trigger(project.filter(|id| !id.is_empty())) - }); + let model_picker = self.model_popover(cx); v_flex() .w(px(720.)) .max_w_full() @@ -3190,22 +3362,19 @@ impl MainView { cx, )) .child( - h_flex() - .gap_2() - .children(trigger.map(|trigger| model_trigger(&trigger, cx))) - .child( - send_button( - !self.composer.read(cx).value().trim().is_empty(), - self.send_hovered, - cx, - ) - .on_hover(cx.listener( - |this, hovered: &bool, _, cx| { - this.send_hovered = *hovered; - cx.notify(); - }, - )), - ), + h_flex().gap_2().children(model_picker).child( + send_button( + !self.composer.read(cx).value().trim().is_empty(), + self.send_hovered, + cx, + ) + .on_hover(cx.listener( + |this, hovered: &bool, _, cx| { + this.send_hovered = *hovered; + cx.notify(); + }, + )), + ), ), ) } @@ -3466,7 +3635,42 @@ fn runtime_icon(mode: RuntimeMode) -> Icons { } /// The web's `ProviderModelPicker` trigger, a `ComposerControl` at `sm`. Clicking does nothing yet. -fn model_trigger(trigger: &Trigger, cx: &App) -> gpui::Stateful { +/// The model chip as a popover trigger. +#[derive(IntoElement)] +struct ModelTrigger { + trigger: Trigger, + open: bool, +} + +impl Selectable for ModelTrigger { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } + + fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + fn is_open(&self) -> bool { + self.open + } +} + +impl RenderOnce for ModelTrigger { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let foreground = cx.theme().foreground; + model_trigger(&self.trigger, self.open, cx).when(self.open, move |chip| { + chip.bg(foreground.opacity(0.06)).text_color(foreground) + }) + } +} + +fn model_trigger(trigger: &Trigger, open: bool, cx: &App) -> gpui::Stateful { let theme = cx.theme(); let (foreground, muted) = (theme.foreground, theme.muted_foreground); div() @@ -3484,8 +3688,7 @@ fn model_trigger(trigger: &Trigger, cx: &App) -> gpui::Stateful { .text_color(foreground.opacity(0.8)) .cursor_pointer() .hover(move |style| style.bg(foreground.opacity(0.06)).text_color(foreground)) - .tooltip(tooltip(trigger.title.clone())) - .on_click(|_, _, _| {}) + .when(!open, |chip| chip.tooltip(tooltip(trigger.title.clone()))) .child(provider_mark(&trigger.mark, 1., cx)) .child( div() From d5190c01a1680320211157327f76a1f09e88ee5b Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 20:41:13 -0500 Subject: [PATCH 45/82] feat(desktop-gpui): pair once and keep the session in the macOS Keychain The app saves the bearer session a pairing token buys (one Keychain item per environment id), reconnects with it on later launches, deletes it when the server answers 401, and forgets it on --unpair. The Electron catalog and DPoP key reading are gone, along with the crates only they used. --- apps/desktop-gpui/Cargo.lock | 225 -------- apps/desktop-gpui/Cargo.toml | 4 - apps/desktop-gpui/README.md | 19 +- apps/desktop-gpui/src/auth.rs | 107 ++-- apps/desktop-gpui/src/catalog.rs | 635 ---------------------- apps/desktop-gpui/src/config.rs | 54 +- apps/desktop-gpui/src/connection.rs | 68 ++- apps/desktop-gpui/src/credentials.rs | 272 ++++++++++ apps/desktop-gpui/src/electron_dpop.rs | 720 ------------------------- apps/desktop-gpui/src/environments.rs | 67 +-- apps/desktop-gpui/src/main.rs | 32 +- apps/desktop-gpui/src/pairing.rs | 212 ++++++++ 12 files changed, 658 insertions(+), 1757 deletions(-) delete mode 100644 apps/desktop-gpui/src/catalog.rs create mode 100644 apps/desktop-gpui/src/credentials.rs delete mode 100644 apps/desktop-gpui/src/electron_dpop.rs create mode 100644 apps/desktop-gpui/src/pairing.rs diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index 2b763c33080d..4965eec0278a 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -488,12 +488,6 @@ dependencies = [ "windows-link 0.2.1", ] -[[package]] -name = "base16ct" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" - [[package]] name = "base62" version = "2.2.6" @@ -512,12 +506,6 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bit-set" version = "0.10.0" @@ -1044,12 +1032,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "cpubits" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -1108,22 +1090,6 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" -[[package]] -name = "crypto-bigint" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c035cb64cba03dad2fb921f3e218785e8cf9a41790da5eb3a1400f62fa2be11" -dependencies = [ - "cpubits", - "ctutils", - "getrandom 0.4.3", - "hybrid-array", - "num-traits", - "rand_core 0.10.1", - "subtle", - "zeroize", -] - [[package]] name = "crypto-common" version = "0.1.7" @@ -1140,9 +1106,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "getrandom 0.4.3", "hybrid-array", - "rand_core 0.10.1", ] [[package]] @@ -1162,7 +1126,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f" dependencies = [ "cmov", - "subtle", ] [[package]] @@ -1183,16 +1146,6 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" -[[package]] -name = "der" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004" -dependencies = [ - "const-oid", - "zeroize", -] - [[package]] name = "deranged" version = "0.5.9" @@ -1356,47 +1309,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" -[[package]] -name = "ecdsa" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" -dependencies = [ - "der", - "digest 0.11.3", - "elliptic-curve", - "rfc6979", - "signature", - "spki", - "zeroize", -] - [[package]] name = "either" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" -[[package]] -name = "elliptic-curve" -version = "0.14.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "900f37e7e18d74a2347389af6cd69e3a30d5cc9e697f40004865679aa75e9c7c" -dependencies = [ - "base16ct", - "crypto-bigint", - "crypto-common 0.2.2", - "digest 0.11.3", - "ff", - "group", - "hybrid-array", - "pkcs8", - "rand_core 0.10.1", - "sec1", - "subtle", - "zeroize", -] - [[package]] name = "embed-resource" version = "3.0.12" @@ -1605,16 +1523,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "ff" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1f686ab92a9fb0eaf188f6c6c87b89490baa6fdb0db4544ba4dc47f7942489f" -dependencies = [ - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "find-msvc-tools" version = "0.1.15" @@ -2643,17 +2551,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "group" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fd1a1c7a5206c5b7a3f5a0d7ccd3ff85d0c8f5133d62a02680255b0004af5f4" -dependencies = [ - "ff", - "rand_core 0.10.1", - "subtle", -] - [[package]] name = "h2" version = "0.4.20" @@ -2856,9 +2753,7 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ - "subtle", "typenum", - "zeroize", ] [[package]] @@ -4491,19 +4386,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "p256" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primefield", - "primeorder", - "sha2 0.11.1", -] - [[package]] name = "parking" version = "2.2.1" @@ -4661,16 +4543,6 @@ dependencies = [ "futures-io", ] -[[package]] -name = "pkcs8" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "pkg-config" version = "0.3.34" @@ -4795,33 +4667,6 @@ dependencies = [ "syn 3.0.9", ] -[[package]] -name = "primefield" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4" -dependencies = [ - "crypto-bigint", - "crypto-common 0.2.2", - "ff", - "rand_core 0.10.1", - "subtle", - "zeroize", -] - -[[package]] -name = "primeorder" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c9f42978c78a00e3d68f69fc03e57a234debae69da4020a4fb588fcdcd07b06" -dependencies = [ - "elliptic-curve", - "once_cell", - "primefield", - "serdect", - "wnaf", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -5378,16 +5223,6 @@ dependencies = [ "zune-jpeg 0.5.15", ] -[[package]] -name = "rfc6979" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b" -dependencies = [ - "crypto-bigint", - "hmac 0.13.0", -] - [[package]] name = "rgb" version = "0.8.53" @@ -5789,20 +5624,6 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" -[[package]] -name = "sec1" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" -dependencies = [ - "base16ct", - "ctutils", - "der", - "hybrid-array", - "subtle", - "zeroize", -] - [[package]] name = "security-framework" version = "3.7.0" @@ -5989,16 +5810,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serdect" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" -dependencies = [ - "base16ct", - "serde", -] - [[package]] name = "sha1" version = "0.11.0" @@ -6073,16 +5884,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" -dependencies = [ - "digest 0.11.3", - "rand_core 0.10.1", -] - [[package]] name = "simd-adler32" version = "0.3.10" @@ -6249,16 +6050,6 @@ dependencies = [ "bitflags 2.13.2", ] -[[package]] -name = "spki" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -6465,10 +6256,8 @@ dependencies = [ name = "t3-desktop-gpui" version = "0.1.0" dependencies = [ - "aes", "alacritty_terminal", "async-channel", - "cbc", "futures-util", "getrandom 0.4.3", "gpui-component", @@ -6479,14 +6268,12 @@ dependencies = [ "hmac 0.13.0", "libc", "markdown", - "p256", "prettyplease", "reqwest", "schemars 0.8.22", "security-framework", "serde", "serde_json", - "sha1", "sha2 0.11.1", "snap", "syn 3.0.9", @@ -8311,18 +8098,6 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wnaf" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "795ca18b3fdb5e62bf982199278341ddcf7ebf7d32e25e212ad05d496e95f6fa" -dependencies = [ - "ff", - "group", - "hybrid-array", - "primefield", -] - [[package]] name = "writeable" version = "0.6.4" diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index 4a271b728869..ef8c98c9af1e 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -6,10 +6,8 @@ license = "MIT" publish = false [dependencies] -aes = "0.8.4" alacritty_terminal = "=0.26.0" async-channel = "2.5.0" -cbc = { version = "0.1.2", features = ["alloc"] } futures-util = "0.3.34" getrandom = "0.4.3" gpui = { package = "gpui-pre", version = "=0.3.11" } @@ -20,11 +18,9 @@ hex = "0.4.3" hmac = "0.13.0" libc = "0.2.191" markdown = "1.0.0" -p256 = { version = "0.14", default-features = false, features = ["ecdsa", "pkcs8", "std"] } reqwest = { version = "0.13.5", default-features = false, features = ["form", "json", "rustls"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.154" -sha1 = "0.11.0" sha2 = "0.11.1" snap = "1" tokio = { version = "1.54.1", features = [ diff --git a/apps/desktop-gpui/README.md b/apps/desktop-gpui/README.md index e9e8f2fe2069..cbc7c134f013 100644 --- a/apps/desktop-gpui/README.md +++ b/apps/desktop-gpui/README.md @@ -23,21 +23,16 @@ The app reads these variables, which the dev runner sets. Without them it shows | `T3CODE_GPUI_SERVER_COMMAND` | Program that starts the server (`node` in dev, `t3` once packaged). | | `T3CODE_GPUI_SERVER_ENTRY` | Optional first argument, the server entry script in dev. | -To see real data, attach to a server that is already running (for example your `vp run dev:desktop`) instead of starting one. That server keeps sole ownership of its database. Mint a one-time pairing token with `node apps/server/src/bin.ts pair --base-dir `, then run the app with: +To see real data, attach to a server that is already running (for example your `vp run dev:desktop`) instead of starting one. That server keeps sole ownership of its database. The app pairs once and stays paired across launches. -| Variable | Meaning | -| --------------------------- | --------------------------------------------------------- | -| `T3CODE_GPUI_SERVER_URL` | HTTP origin of the running server. Takes precedence. | -| `T3CODE_GPUI_PAIRING_TOKEN` | The `Token:` value printed by `pair`. It works only once. | +| Variable | Meaning | +| --------------------------- | ------------------------------------------------------------------------------------------------------ | +| `T3CODE_GPUI_SERVER_URL` | HTTP origin of the running server. Takes precedence. | +| `T3CODE_GPUI_PAIRING_TOKEN` | Only for the first launch: the `Token:` value or `/pair#token=...` link from `t3 pair`. It works once. | -When attached, the project picker also shows the Electron app's environment badge ("Also on ") for projects that exist on your other saved environments. It reads the Electron app's `connection-catalog.json`, decrypting it with the safeStorage key from the macOS Keychain (the OS may ask for access once). Only direct `http` environments with a saved bearer token are used; relay, T3 Connect, SSH and `https` entries are skipped with a log line. +Mint a token with `node apps/server/src/bin.ts pair --base-dir ` and launch with both variables. The server answers with a bearer session (30 days), which the app keeps in the macOS Keychain (service `T3 Code (GPUI) Environment Sessions`, one item per environment id). Later launches need only `T3CODE_GPUI_SERVER_URL`. When the server rejects the saved session (revoked in Settings or expired), the app deletes it and shows how to pair again. To forget a pairing yourself, launch once with `--unpair` (for example `cargo run -- --unpair`) against that server. Nothing is read from the Electron app. -| Variable | Meaning | -| -------------------------------- | ------------------------------------------------------------------------------------ | -| `T3CODE_GPUI_CONNECTION_CATALOG` | Catalog path. Defaults to `connection-catalog.json` in `T3CODE_GPUI_HOME`. | -| `T3CODE_GPUI_ELECTRON_IDB` | Electron IndexedDB LevelDB directory (DPoP key). Defaults to the `t3code-dev` app's. | - -TODO(gpui-credentials): HACK. Saved T3 Connect tokens (`remoteDpopTokens`) are used by reading the Electron app's private IndexedDB for its non-extractable DPoP key (`src/electron_dpop.rs`). Replace with GPUI's own pairing + Keychain before shipping. +When attached, the project picker also shows an environment badge ("Also on ") for projects that exist on the other environments this app is paired with. Pairing with several servers over time (one launch each) is what fills that list. Other scripts: diff --git a/apps/desktop-gpui/src/auth.rs b/apps/desktop-gpui/src/auth.rs index fd5c0ed429df..e903a1c2e0ad 100644 --- a/apps/desktop-gpui/src/auth.rs +++ b/apps/desktop-gpui/src/auth.rs @@ -2,13 +2,10 @@ //! Mirrors packages/shared/src/desktopBootstrapToken.ts and //! packages/client-runtime/src/authorization/remote.ts. -use crate::electron_dpop; use hmac::{Hmac, KeyInit, Mac}; -use p256::ecdsa::SigningKey; use serde::Deserialize; use sha2::Sha256; use std::fmt::Write; -use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; const TOKEN_WINDOW_MS: u64 = 12 * 60 * 60 * 1000; @@ -75,6 +72,14 @@ impl From for AuthError { #[derive(Deserialize)] struct AccessTokenResult { access_token: String, + #[serde(default)] + expires_in: Option, +} + +/// A bearer session and how long the server says it lasts. +pub struct Session { + pub token: String, + pub expires_in_secs: Option, } #[derive(Deserialize)] @@ -82,39 +87,50 @@ struct TicketResult { ticket: String, } -/// What the app proves itself with: the secret it handed a server it spawned, -/// a pairing token minted by `t3 pair` for a server it attaches to, or a bearer -/// session another client already saved for it (the Electron app's catalog). +/// What the app proves itself with: the secret it handed a server it spawned, a pairing token +/// minted by `t3 pair` for a server it attaches to, or the bearer session that pairing yielded +/// and the Keychain kept (`credentials`). pub enum Credential { DesktopSecret(String), Pairing(String), Bearer(String), - // TODO(gpui-credentials): HACK. Reads Electron's private IndexedDB and non-extractable DPoP key. Replace with GPUI's own pairing + Keychain before shipping. - /// A DPoP-bound access token and the proof key it is bound to (`Authorization: DPoP`). - Dpop { - token: String, - key: Arc, - }, } impl Credential { fn subject_token(&self) -> String { match self { Self::DesktopSecret(secret) => bootstrap_token(secret, now_ms()), - Self::Pairing(token) | Self::Bearer(token) | Self::Dpop { token, .. } => token.clone(), + Self::Pairing(token) | Self::Bearer(token) => token.clone(), } } } -/// RFC 8693 exchange of a bootstrap credential for a bearer session. -pub async fn exchange_bearer( +/// The token from what a user pastes: a bare token, or the `/pair#token=...` link `t3` prints. +pub fn pairing_token(input: &str) -> String { + let input = input.trim(); + input + .split_once('#') + .and_then(|(_, fragment)| { + fragment + .split('&') + .find_map(|pair| pair.strip_prefix("token=")) + }) + .unwrap_or(input) + .to_owned() +} + +/// RFC 8693 exchange of a bootstrap credential for a bearer session. A saved bearer is already +/// the session, so there is nothing to exchange and no expiry to report. +pub async fn exchange_session( client: &reqwest::Client, http_base: &str, credential: &Credential, -) -> Result { - // A saved bearer or DPoP access token is already the session; there is nothing to exchange. - if let Credential::Bearer(token) | Credential::Dpop { token, .. } = credential { - return Ok(token.clone()); +) -> Result { + if let Credential::Bearer(token) = credential { + return Ok(Session { + token: token.clone(), + expires_in_secs: None, + }); } let credential = credential.subject_token(); let response = client @@ -134,32 +150,37 @@ pub async fn exchange_bearer( return Err(AuthError::Rejected(status.as_u16())); } let result: AccessTokenResult = response.error_for_status()?.json().await?; - Ok(result.access_token) + Ok(Session { + token: result.access_token, + expires_in_secs: result.expires_in, + }) +} + +pub async fn exchange_bearer( + client: &reqwest::Client, + http_base: &str, + credential: &Credential, +) -> Result { + exchange_session(client, http_base, credential) + .await + .map(|session| session.token) } /// Mints an RPC WebSocket ticket. A server without audience routes answers 404 -/// for `/:use`, and its bare-path tickets are valid for RPC. A DPoP credential signs a proof for -/// each path it tries (packages/client-runtime/src/authorization/remote.ts). +/// for `/:use`, and its bare-path tickets are valid for RPC +/// (packages/client-runtime/src/authorization/remote.ts). pub async fn issue_ws_ticket( client: &reqwest::Client, http_base: &str, - credential: &Credential, bearer: &str, ) -> Result { let mint = |path: &'static str| async move { - let url = format!("{http_base}{path}"); - let request = client.post(&url); - let request = match credential { - Credential::Dpop { key, .. } => { - let proof = electron_dpop::create_proof(key, "POST", &url, Some(bearer), now_ms()) - .map_err(AuthError::Failed)?; - request - .header("authorization", format!("DPoP {bearer}")) - .header("dpop", proof) - } - _ => request.bearer_auth(bearer), - }; - request.send().await.map_err(AuthError::from) + client + .post(format!("{http_base}{path}")) + .bearer_auth(bearer) + .send() + .await + .map_err(AuthError::from) }; let mut response = mint("/api/auth/websocket-ticket/rpc").await?; if response.status() == reqwest::StatusCode::NOT_FOUND { @@ -215,6 +236,20 @@ mod tests { ); } + #[test] + fn pairing_input_may_be_a_token_or_the_printed_link() { + assert_eq!(pairing_token(" abc123 "), "abc123"); + assert_eq!( + pairing_token("http://localhost:3773/pair#token=abc123"), + "abc123" + ); + assert_eq!(pairing_token("https://h/pair#a=1&token=xyz"), "xyz"); + assert_eq!( + pairing_token("https://h/pair#nothing"), + "https://h/pair#nothing" + ); + } + #[test] fn secrets_are_random_hex() { let first = generate_secret().expect("secret"); diff --git a/apps/desktop-gpui/src/catalog.rs b/apps/desktop-gpui/src/catalog.rs deleted file mode 100644 index c57805df4bde..000000000000 --- a/apps/desktop-gpui/src/catalog.rs +++ /dev/null @@ -1,635 +0,0 @@ -//! The Electron desktop app's saved connections: `connection-catalog.json` holds the catalog -//! encrypted with Electron safeStorage (DesktopConnectionCatalogStore.ts). The plaintext schema is -//! `ConnectionCatalogDocument` in packages/client-runtime/src/platform/storageDocument.ts. -//! Read-only: this module never writes the catalog and never logs a decrypted value. -//! -//! TODO(gpui-credentials): HACK. Reads Electron's private IndexedDB and non-extractable DPoP key. Replace with GPUI's own pairing + Keychain before shipping. -//! (`remoteDpopTokens` are only usable with Electron's DPoP key; see `electron_dpop`.) - -use crate::auth::{self, Credential}; -use crate::electron_dpop; -use aes::cipher::{BlockDecryptMut, KeyIvInit, block_padding::Pkcs7}; -use hmac::{Hmac, KeyInit, Mac}; -use p256::ecdsa::SigningKey; -use serde::Deserialize; -use sha1::Sha1; -use std::collections::HashMap; -use std::path::Path; -use std::sync::Arc; - -type Aes128CbcDec = cbc::Decryptor; - -/// Chromium os_crypt_mac: key = PBKDF2-HMAC-SHA1(password, "saltysalt", 1003) of 16 bytes, -/// IV = 16 spaces, ciphertext prefixed with "v10". -const SAFE_STORAGE_PREFIX: &[u8] = b"v10"; -const SAFE_STORAGE_SALT: &[u8] = b"saltysalt"; -const SAFE_STORAGE_ITERATIONS: u32 = 1003; - -/// Keychain items Electron's safeStorage uses as (service, account). The service is named after -/// the app at launch: a dev launcher that runs the stock Electron binary stores `Electron`. -#[cfg(target_os = "macos")] -const KEYCHAIN_ITEMS: [(&str, &str); 2] = [ - ("Electron Safe Storage", "Electron Key"), - ("T3 Code (Dev) Safe Storage", "T3 Code (Dev) Key"), -]; - -pub const CATALOG_FILE: &str = "connection-catalog.json"; - -/// A directly reachable environment: a plain `http` base URL and the bearer token saved for it. -pub struct SavedEnvironment { - pub environment_id: String, - pub label: String, - pub http_base: String, - pub credential: Credential, -} - -/// What `load` found: environments to connect to, and the `(label, kind)` of those it cannot. -#[derive(Default)] -pub struct Catalog { - pub environments: Vec, - pub skipped: Vec<(String, &'static str)>, - /// One-line explanations for environments that are skipped for a specific reason. - pub notices: Vec, -} - -#[derive(Deserialize)] -struct Envelope { - version: u32, - #[serde(rename = "encryptedCatalog")] - encrypted_catalog: String, -} - -// Variant names are the wire `_tag` values. -#[allow(clippy::enum_variant_names)] -#[derive(Deserialize)] -#[serde(tag = "_tag")] -enum Target { - BearerConnectionTarget { - #[serde(rename = "environmentId")] - environment_id: String, - label: String, - #[serde(rename = "connectionId")] - connection_id: String, - }, - RelayConnectionTarget { - label: String, - }, - SshConnectionTarget { - label: String, - }, - #[serde(other)] - Unknown, -} - -#[derive(Deserialize)] -#[serde(tag = "_tag")] -enum Profile { - BearerConnectionProfile { - #[serde(rename = "connectionId")] - connection_id: String, - #[serde(rename = "httpBaseUrl")] - http_base_url: String, - authorization: Option, - }, - #[serde(other)] - Other, -} - -#[derive(Deserialize)] -#[serde(tag = "_tag")] -enum StoredToken { - BearerConnectionCredential { - token: String, - }, - #[serde(other)] - Other, -} - -#[derive(Deserialize)] -struct StoredCredential { - #[serde(rename = "connectionId")] - connection_id: String, - credential: StoredToken, -} - -/// `RemoteDpopAccessToken` (packages/client-runtime/src/authorization/tokenStore.ts). -#[derive(Deserialize)] -struct DpopToken { - #[serde(rename = "environmentId")] - environment_id: String, - endpoint: DpopEndpoint, - #[serde(rename = "accessToken")] - access_token: String, - #[serde(rename = "expiresAtEpochMs")] - expires_at_epoch_ms: f64, - #[serde(rename = "dpopThumbprint")] - dpop_thumbprint: String, -} - -#[derive(Deserialize)] -struct DpopEndpoint { - #[serde(rename = "httpBaseUrl")] - http_base_url: String, -} - -#[derive(Deserialize)] -struct Document { - #[serde(default)] - targets: Vec, - #[serde(default)] - profiles: Vec, - #[serde(default)] - credentials: Vec, - #[serde(rename = "remoteDpopTokens", default)] - remote_dpop_tokens: Vec, - #[serde(rename = "disabledEnvironmentIds", default)] - disabled_environment_ids: Vec, -} - -/// Why a bearer target cannot be dialed directly, as a short kind for the log. -fn bearer_kind(http_base: &str, authorization: Option<&str>) -> Option<&'static str> { - if authorization == Some("t3-connect") { - return Some("t3-connect"); - } - // tokio-tungstenite is built without TLS, so a wss socket cannot open. - (!http_base.starts_with("http://")).then_some("https") -} - -type KeyLoader<'a> = &'a mut dyn FnMut() -> Result, String>; - -/// Decodes the decrypted catalog JSON into the environments this client can reach. -/// `load_key` supplies Electron's DPoP key, and is called at most once, only when a saved -/// DPoP token is worth using. -pub fn parse(plaintext: &str, now_ms: u64, load_key: KeyLoader) -> Result { - // serde messages can quote the offending value, and this document holds tokens. - let document: Document = serde_json::from_str(plaintext).map_err(|error| { - format!( - "unreadable catalog document at {}:{}", - error.line(), - error.column() - ) - })?; - let profiles: HashMap<&str, (&str, Option<&str>)> = document - .profiles - .iter() - .filter_map(|profile| match profile { - Profile::BearerConnectionProfile { - connection_id, - http_base_url, - authorization, - } => Some(( - connection_id.as_str(), - (http_base_url.as_str(), authorization.as_deref()), - )), - Profile::Other => None, - }) - .collect(); - let tokens: HashMap<&str, &str> = document - .credentials - .iter() - .filter_map(|stored| match &stored.credential { - StoredToken::BearerConnectionCredential { token } => { - Some((stored.connection_id.as_str(), token.as_str())) - } - StoredToken::Other => None, - }) - .collect(); - let dpop_tokens: HashMap<&str, &DpopToken> = document - .remote_dpop_tokens - .iter() - .map(|token| (token.environment_id.as_str(), token)) - .collect(); - let mut key: Option, String>> = None; - let mut catalog = Catalog::default(); - for target in &document.targets { - match target { - Target::RelayConnectionTarget { label } => { - catalog.skipped.push((label.clone(), "relay")); - } - Target::SshConnectionTarget { label } => catalog.skipped.push((label.clone(), "ssh")), - Target::Unknown => {} - Target::BearerConnectionTarget { - environment_id, - label, - connection_id, - } => { - if document.disabled_environment_ids.contains(environment_id) { - continue; - } - let Some((http_base, authorization)) = profiles.get(connection_id.as_str()) else { - catalog.skipped.push((label.clone(), "no-profile")); - continue; - }; - let bearer = tokens.get(connection_id.as_str()); - // Electron signs in with the DPoP token for T3 Connect profiles, and for any - // profile that has no bearer of its own. - let dpop = dpop_tokens - .get(environment_id.as_str()) - .filter(|_| *authorization == Some("t3-connect") || bearer.is_none()); - if let Some(saved) = dpop { - match dpop_environment( - saved, - environment_id, - label, - http_base, - now_ms, - &mut key, - load_key, - ) { - Ok(environment) => catalog.environments.push(environment), - Err(DpopSkip::Notice(notice)) => catalog.notices.push(notice), - Err(DpopSkip::Kind(kind)) => catalog.skipped.push((label.clone(), kind)), - } - continue; - } - if let Some(kind) = bearer_kind(http_base, *authorization) { - catalog.skipped.push((label.clone(), kind)); - continue; - } - let Some(token) = bearer else { - catalog.skipped.push((label.clone(), "no-credential")); - continue; - }; - catalog.environments.push(SavedEnvironment { - environment_id: environment_id.clone(), - label: label.clone(), - http_base: http_base.trim_end_matches('/').to_owned(), - credential: Credential::Bearer((*token).to_owned()), - }); - } - } - } - // The same environment can be saved more than once (one target per pairing). Keep the first - // connectable entry, and do not report siblings of a connectable environment as skipped. - let mut seen = std::collections::HashSet::new(); - catalog - .environments - .retain(|saved| seen.insert(saved.environment_id.clone())); - catalog.skipped.retain(|(skipped, _)| { - !catalog - .environments - .iter() - .any(|saved| &saved.label == skipped) - }); - Ok(catalog) -} - -enum DpopSkip { - Notice(String), - Kind(&'static str), -} - -/// Maps a saved DPoP token to a connectable environment, or says why it cannot be used. The -/// direct profile address wins over the token's own endpoint; both must be plain `http`. -fn dpop_environment( - saved: &DpopToken, - environment_id: &str, - label: &str, - profile_base: &str, - now_ms: u64, - key: &mut Option, String>>, - load_key: KeyLoader, -) -> Result { - // The token is minted through the T3 Connect relay, which this client cannot reach, so an - // expired one cannot be renewed here. - if saved.expires_at_epoch_ms <= now_ms as f64 { - return Err(DpopSkip::Notice(format!( - "{label}: saved token expired, open the Electron app to refresh" - ))); - } - let http_base = [profile_base, saved.endpoint.http_base_url.as_str()] - .into_iter() - .find(|base| base.starts_with("http://")) - .ok_or(DpopSkip::Kind("https"))?; - let key = key.get_or_insert_with(load_key); - let key = key - .as_ref() - .map_err(|error| DpopSkip::Notice(format!("{label}: no Electron DPoP key ({error})")))?; - if electron_dpop::thumbprint(key) != saved.dpop_thumbprint { - return Err(DpopSkip::Notice(format!( - "{label}: Electron's DPoP key does not match the saved token" - ))); - } - Ok(SavedEnvironment { - environment_id: environment_id.to_owned(), - label: label.to_owned(), - http_base: http_base.trim_end_matches('/').to_owned(), - credential: Credential::Dpop { - token: saved.access_token.clone(), - key: key.clone(), - }, - }) -} - -/// PBKDF2-HMAC-SHA1 for one 16-byte block (SHA-1 yields 20 bytes, so a single block suffices). -fn pbkdf2_sha1_block(password: &[u8], salt: &[u8], iterations: u32) -> [u8; 16] { - let prf = |data: &[u8]| { - let mut mac = Hmac::::new_from_slice(password).expect("HMAC takes any key length"); - mac.update(data); - mac.finalize().into_bytes() - }; - let mut first = salt.to_vec(); - first.extend_from_slice(&1u32.to_be_bytes()); - let mut u = prf(&first); - let mut t = u; - for _ in 1..iterations { - u = prf(&u); - for (acc, byte) in t.iter_mut().zip(u.iter()) { - *acc ^= byte; - } - } - let mut key = [0u8; 16]; - key.copy_from_slice(&t[..16]); - key -} - -/// Decrypts Electron safeStorage ciphertext (`v10` + AES-128-CBC) with the Keychain password. -pub fn decrypt(ciphertext: &[u8], password: &[u8]) -> Result, String> { - let body = ciphertext - .strip_prefix(SAFE_STORAGE_PREFIX) - .ok_or("ciphertext is not v10 safeStorage data")?; - let key = pbkdf2_sha1_block(password, SAFE_STORAGE_SALT, SAFE_STORAGE_ITERATIONS); - Aes128CbcDec::new(&key.into(), &[b' '; 16].into()) - .decrypt_padded_vec_mut::(body) - .map_err(|_| "could not decrypt the catalog (wrong Keychain key?)".to_owned()) -} - -#[cfg(target_os = "macos")] -fn keychain_password() -> Result, String> { - use security_framework::passwords::get_generic_password; - let mut last = String::new(); - for (service, account) in KEYCHAIN_ITEMS { - match get_generic_password(service, account) { - Ok(password) => return Ok(password), - Err(error) => last = format!("{service}: {error}"), - } - } - Err(format!("could not read the safeStorage key ({last})")) -} - -#[cfg(not(target_os = "macos"))] -fn keychain_password() -> Result, String> { - Err("reading the safeStorage key is only supported on macOS".to_owned()) -} - -/// Reads, decrypts and decodes the catalog at `path`. Blocking: the Keychain may prompt. -pub fn load(path: &Path) -> Result { - let raw = std::fs::read_to_string(path).map_err(|error| error.to_string())?; - let envelope: Envelope = - serde_json::from_str(&raw).map_err(|error| format!("unreadable catalog file: {error}"))?; - if envelope.version != 1 { - return Err(format!("unsupported catalog version {}", envelope.version)); - } - let ciphertext = b64::decode(&envelope.encrypted_catalog)?; - let plaintext = decrypt(&ciphertext, &keychain_password()?)?; - let plaintext = String::from_utf8(plaintext).map_err(|_| "catalog is not UTF-8".to_owned())?; - // TODO(gpui-credentials): HACK. Reads Electron's private IndexedDB and non-extractable DPoP key. Replace with GPUI's own pairing + Keychain before shipping. - parse(&plaintext, auth::now_ms(), &mut || { - let dir = electron_dpop::default_idb_dir().ok_or("no IndexedDB location")?; - electron_dpop::load_key(&dir).map(Arc::new) - }) -} - -/// Standard base64 (RFC 4648, padded), enough for the envelope; no extra crate. -pub(crate) mod b64 { - pub fn decode(input: &str) -> Result, String> { - let mut out = Vec::with_capacity(input.len() * 3 / 4); - let (mut buffer, mut bits) = (0u32, 0u32); - for byte in input.trim().bytes() { - let value = match byte { - b'A'..=b'Z' => byte - b'A', - b'a'..=b'z' => byte - b'a' + 26, - b'0'..=b'9' => byte - b'0' + 52, - b'+' => 62, - b'/' => 63, - b'=' => break, - _ => return Err("catalog is not valid base64".to_owned()), - }; - buffer = (buffer << 6) | u32::from(value); - bits += 6; - if bits >= 8 { - bits -= 8; - out.push((buffer >> bits) as u8); - buffer &= (1 << bits) - 1; - } - } - Ok(out) - } - - #[cfg(test)] - pub fn encode(input: &[u8]) -> String { - const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; - let mut out = String::new(); - for chunk in input.chunks(3) { - let n = chunk - .iter() - .enumerate() - .fold(0u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i)); - for i in 0..=chunk.len() { - out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char); - } - out.extend(std::iter::repeat_n('=', 3 - chunk.len())); - } - out - } -} - -#[cfg(test)] -mod tests { - use super::*; - use aes::cipher::BlockEncryptMut; - - type Aes128CbcEnc = cbc::Encryptor; - - /// Encrypts like safeStorage, with a synthetic password. - fn seal(plaintext: &[u8], password: &[u8]) -> Vec { - let key = pbkdf2_sha1_block(password, SAFE_STORAGE_SALT, SAFE_STORAGE_ITERATIONS); - let mut sealed = SAFE_STORAGE_PREFIX.to_vec(); - sealed.extend( - Aes128CbcEnc::new(&key.into(), &[b' '; 16].into()) - .encrypt_padded_vec_mut::(plaintext), - ); - sealed - } - - #[test] - fn pbkdf2_matches_the_rfc_6070_vector() { - // RFC 6070, P="password", S="salt", c=2, first 16 bytes of the derived key. - let key = pbkdf2_sha1_block(b"password", b"salt", 2); - assert_eq!(hex::encode(key), "ea6c014dc72d6f8ccd1ed92ace1d41f0"); - } - - #[test] - fn base64_round_trips() { - for sample in [&b""[..], b"a", b"ab", b"abc", b"abcd", &[0, 255, 128, 7, 9]] { - assert_eq!(b64::decode(&b64::encode(sample)).unwrap(), sample); - } - assert!(b64::decode("a*b").is_err()); - } - - #[test] - fn decrypts_what_safe_storage_would_produce() { - let sealed = seal(b"synthetic plaintext", b"synthetic-password"); - assert_eq!( - decrypt(&sealed, b"synthetic-password").unwrap(), - b"synthetic plaintext" - ); - assert!(decrypt(&sealed, b"another-password").is_err()); - assert!(decrypt(b"v11whatever", b"synthetic-password").is_err()); - assert!(decrypt(&sealed[..sealed.len() - 1], b"synthetic-password").is_err()); - } - - fn no_key() -> Result, String> { - Err("no key".to_owned()) - } - - const DOCUMENT: &str = r#"{ - "schemaVersion": 1, - "targets": [ - {"_tag":"BearerConnectionTarget","environmentId":"env-direct","label":"Direct","connectionId":"bearer:env-direct"}, - {"_tag":"BearerConnectionTarget","environmentId":"env-off","label":"Off","connectionId":"bearer:env-off"}, - {"_tag":"BearerConnectionTarget","environmentId":"env-connect","label":"Tunnel","connectionId":"bearer:env-connect"}, - {"_tag":"BearerConnectionTarget","environmentId":"env-tls","label":"Secure","connectionId":"bearer:env-tls"}, - {"_tag":"BearerConnectionTarget","environmentId":"env-bare","label":"Bare","connectionId":"bearer:env-bare"}, - {"_tag":"RelayConnectionTarget","environmentId":"env-relay","label":"Relayed"}, - {"_tag":"SshConnectionTarget","environmentId":"env-ssh","label":"Shell","connectionId":"ssh:env-ssh"}, - {"_tag":"FutureTarget","environmentId":"x","label":"Future"} - ], - "profiles": [ - {"_tag":"BearerConnectionProfile","connectionId":"bearer:env-direct","environmentId":"env-direct","label":"Direct","httpBaseUrl":"http://10.0.0.2:3773/","wsBaseUrl":"ws://10.0.0.2:3773/"}, - {"_tag":"BearerConnectionProfile","connectionId":"bearer:env-off","environmentId":"env-off","label":"Off","httpBaseUrl":"http://10.0.0.3:3773","wsBaseUrl":"ws://10.0.0.3:3773"}, - {"_tag":"BearerConnectionProfile","connectionId":"bearer:env-connect","environmentId":"env-connect","label":"Tunnel","httpBaseUrl":"http://tunnel.invalid","wsBaseUrl":"ws://tunnel.invalid","authorization":"t3-connect"}, - {"_tag":"BearerConnectionProfile","connectionId":"bearer:env-tls","environmentId":"env-tls","label":"Secure","httpBaseUrl":"https://host.invalid","wsBaseUrl":"wss://host.invalid"}, - {"_tag":"BearerConnectionProfile","connectionId":"bearer:env-bare","environmentId":"env-bare","label":"Bare","httpBaseUrl":"http://10.0.0.4:3773","wsBaseUrl":"ws://10.0.0.4:3773"}, - {"_tag":"SshConnectionProfile","connectionId":"ssh:env-ssh","environmentId":"env-ssh","label":"Shell","target":{}} - ], - "credentials": [ - {"connectionId":"bearer:env-direct","credential":{"_tag":"BearerConnectionCredential","token":"synthetic-token"}}, - {"connectionId":"bearer:env-off","credential":{"_tag":"BearerConnectionCredential","token":"synthetic-token-2"}} - ], - "remoteDpopTokens": [], - "disabledEnvironmentIds": ["env-off"] - }"#; - - #[test] - fn parse_keeps_direct_bearer_environments_and_names_the_rest() { - let catalog = parse(DOCUMENT, 0, &mut no_key).unwrap(); - assert_eq!(catalog.environments.len(), 1); - let direct = &catalog.environments[0]; - assert_eq!(direct.environment_id, "env-direct"); - assert_eq!(direct.label, "Direct"); - assert_eq!(direct.http_base, "http://10.0.0.2:3773"); - assert!(matches!(&direct.credential, Credential::Bearer(t) if t == "synthetic-token")); - assert_eq!( - catalog.skipped, - [ - ("Tunnel".to_owned(), "t3-connect"), - ("Secure".to_owned(), "https"), - ("Bare".to_owned(), "no-credential"), - ("Relayed".to_owned(), "relay"), - ("Shell".to_owned(), "ssh"), - ] - ); - assert!(parse("{", 0, &mut no_key).is_err()); - } - - fn dpop_document(thumbprint: &str, expires_at: u64, authorization: &str, base: &str) -> String { - format!( - r#"{{"targets":[{{"_tag":"BearerConnectionTarget","environmentId":"env-a","label":"Devbox","connectionId":"bearer:env-a"}}], - "profiles":[{{"_tag":"BearerConnectionProfile","connectionId":"bearer:env-a","httpBaseUrl":"{base}"{authorization}}}], - "credentials":[], - "remoteDpopTokens":[{{"environmentId":"env-a","label":"Devbox","accessToken":"synthetic-access", - "endpoint":{{"httpBaseUrl":"https://relay.invalid","wsBaseUrl":"wss://relay.invalid","providerKind":"t3_relay"}}, - "expiresAtEpochMs":{expires_at},"dpopThumbprint":"{thumbprint}"}}]}}"# - ) - } - - #[test] - fn dpop_tokens_become_credentials_when_fresh_and_bound_to_the_electron_key() { - let mut seed = [0u8; 32]; - getrandom::fill(&mut seed).unwrap(); - let key = Arc::new(SigningKey::from_slice(&seed).unwrap()); - let bound = electron_dpop::thumbprint(&key); - let mut loads = 0; - let mut load = || { - loads += 1; - Ok(key.clone()) - }; - - let fresh = dpop_document(&bound, 2_000, "", "http://10.0.0.5:3773/"); - let catalog = parse(&fresh, 1_000, &mut load).unwrap(); - assert_eq!(catalog.environments.len(), 1); - let saved = &catalog.environments[0]; - assert_eq!(saved.http_base, "http://10.0.0.5:3773"); - assert!(matches!( - &saved.credential, - Credential::Dpop { token, .. } if token == "synthetic-access" - )); - - // The token's own relay endpoint is https, so a t3-connect profile still dials its direct address. - let connect = dpop_document( - &bound, - 2_000, - r#","authorization":"t3-connect""#, - "http://10.0.0.5:3773", - ); - assert_eq!( - parse(&connect, 1_000, &mut load) - .unwrap() - .environments - .len(), - 1 - ); - - let expired = parse(&fresh, 2_000, &mut load).unwrap(); - assert!(expired.environments.is_empty()); - assert_eq!( - expired.notices, - ["Devbox: saved token expired, open the Electron app to refresh"] - ); - - let other = dpop_document("someone-else", 2_000, "", "http://10.0.0.5:3773"); - let mismatched = parse(&other, 1_000, &mut load).unwrap(); - assert!(mismatched.environments.is_empty()); - assert!(mismatched.notices[0].contains("does not match")); - - let tls = dpop_document(&bound, 2_000, "", "https://host.invalid"); - assert_eq!( - parse(&tls, 1_000, &mut load).unwrap().skipped, - [("Devbox".to_owned(), "https")] - ); - - let keyless = parse(&fresh, 1_000, &mut no_key).unwrap(); - assert!(keyless.environments.is_empty()); - assert!(keyless.notices[0].contains("no Electron DPoP key")); - // Expired tokens never touch the IndexedDB. - assert_eq!(loads, 3); - } - - #[test] - fn load_reads_the_envelope_and_rejects_other_versions() { - let dir = std::env::temp_dir().join(format!("t3-gpui-catalog-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let path = dir.join(CATALOG_FILE); - std::fs::write(&path, r#"{"version":2,"encryptedCatalog":""}"#).unwrap(); - assert!(load(&path).err().unwrap().contains("version")); - std::fs::write(&path, "nope").unwrap(); - assert!(load(&path).is_err()); - std::fs::remove_dir_all(&dir).unwrap(); - assert!(load(&path).is_err()); - } - - #[test] - fn duplicate_saves_of_one_environment_collapse_to_the_connectable_one() { - let document = r#"{"targets":[ - {"_tag":"BearerConnectionTarget","environmentId":"e","label":"Box","connectionId":"c1"}, - {"_tag":"BearerConnectionTarget","environmentId":"e","label":"Box","connectionId":"c2"}], - "profiles":[ - {"_tag":"BearerConnectionProfile","connectionId":"c1","httpBaseUrl":"http://10.0.0.9:1"}, - {"_tag":"BearerConnectionProfile","connectionId":"c2","httpBaseUrl":"http://10.0.0.9:1"}], - "credentials":[{"connectionId":"c2","credential":{"_tag":"BearerConnectionCredential","token":"t"}}]}"#; - let catalog = parse(document, 0, &mut no_key).unwrap(); - assert_eq!(catalog.environments.len(), 1); - assert!(catalog.skipped.is_empty()); - } -} diff --git a/apps/desktop-gpui/src/config.rs b/apps/desktop-gpui/src/config.rs index 6aa1cfaf930e..866b7984687f 100644 --- a/apps/desktop-gpui/src/config.rs +++ b/apps/desktop-gpui/src/config.rs @@ -6,29 +6,14 @@ pub const ENV_COMMAND: &str = "T3CODE_GPUI_SERVER_COMMAND"; pub const ENV_ENTRY: &str = "T3CODE_GPUI_SERVER_ENTRY"; pub const ENV_ATTACH_URL: &str = "T3CODE_GPUI_SERVER_URL"; pub const ENV_PAIRING_TOKEN: &str = "T3CODE_GPUI_PAIRING_TOKEN"; -pub const ENV_CONNECTION_CATALOG: &str = "T3CODE_GPUI_CONNECTION_CATALOG"; - -/// The Electron app's saved connections: the override, else the catalog inside the T3 home. -pub fn connection_catalog_path() -> Option { - catalog_path_from(|name| std::env::var(name).ok()) -} - -fn catalog_path_from(get: impl Fn(&str) -> Option) -> Option { - let non_empty = |name| get(name).filter(|value| !value.trim().is_empty()); - non_empty(ENV_CONNECTION_CATALOG) - .map(std::path::PathBuf::from) - .or_else(|| { - non_empty(ENV_HOME) - .map(|home| std::path::Path::new(&home).join(crate::catalog::CATALOG_FILE)) - }) -} /// A server someone else runs (a dev server, `npx t3`, a remote host), reached -/// with a pairing token instead of being spawned. +/// with the session saved from an earlier pairing, or a pairing token for the first time. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Attach { pub http_base: String, - pub pairing_token: String, + /// A bare token or the `/pair#token=...` link; absent once the app is paired. + pub pairing_token: Option, } impl Attach { @@ -44,7 +29,8 @@ impl Attach { }; Some(Self { http_base: non_empty(ENV_ATTACH_URL)?.trim_end_matches('/').to_owned(), - pairing_token: non_empty(ENV_PAIRING_TOKEN)?, + pairing_token: non_empty(ENV_PAIRING_TOKEN) + .map(|input| crate::auth::pairing_token(&input)), }) } } @@ -129,33 +115,25 @@ mod tests { } #[test] - fn attach_needs_url_and_token() { + fn attach_needs_a_url_and_the_token_is_optional() { let attach = Attach::from_lookup(lookup(&[ (ENV_ATTACH_URL, "http://127.0.0.1:13773/"), (ENV_PAIRING_TOKEN, "tok"), ])) .expect("attach"); assert_eq!(attach.http_base, "http://127.0.0.1:13773"); + assert_eq!(attach.pairing_token.as_deref(), Some("tok")); + let paired = Attach::from_lookup(lookup(&[(ENV_ATTACH_URL, "http://h")])).expect("attach"); + assert_eq!(paired.pairing_token, None); assert_eq!( - Attach::from_lookup(lookup(&[(ENV_ATTACH_URL, "http://h")])), + Attach::from_lookup(lookup(&[(ENV_PAIRING_TOKEN, "tok")])), None ); - } - - #[test] - fn catalog_path_prefers_the_override_then_the_t3_home() { - let path = |pairs| catalog_path_from(lookup(pairs)); - assert_eq!( - path(&[ - (ENV_CONNECTION_CATALOG, "/c/catalog.json"), - (ENV_HOME, "/h") - ]), - Some("/c/catalog.json".into()) - ); - assert_eq!( - path(&[(ENV_HOME, "/h")]), - Some("/h/connection-catalog.json".into()) - ); - assert_eq!(path(&[]), None); + let link = Attach::from_lookup(lookup(&[ + (ENV_ATTACH_URL, "http://h"), + (ENV_PAIRING_TOKEN, "http://h/pair#token=abc"), + ])) + .expect("attach"); + assert_eq!(link.pairing_token.as_deref(), Some("abc")); } } diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 1829dcf457fd..31be261f03b2 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -3,7 +3,6 @@ use crate::auth::{self, AuthError, Credential}; use crate::details::VcsStatus; use crate::diff; -use crate::electron_dpop; use crate::projects::MachineKind; use crate::providers::Catalog; use crate::rpc::{ @@ -254,32 +253,22 @@ fn socket_url(http_base: &str, ticket: &str) -> Result { .map_err(|error| error.to_string()) } -/// The HTTP request a WebSocket upgrade is, for the proof's `htu`: scheme swapped, no query. -fn upgrade_htu(url: &reqwest::Url) -> String { - let scheme = if url.scheme() == "wss" { - "https" - } else { - "http" - }; - let port = url - .port() - .map(|port| format!(":{port}")) - .unwrap_or_default(); - format!( - "{scheme}://{}{port}{}", - url.host_str().unwrap_or_default(), - url.path() - ) +/// Why `run` stopped: only a saved session the server no longer honors ends it. +#[derive(Debug, PartialEq, Eq)] +pub enum Ended { + /// The server answered 401 to a saved session (revoked or expired); pairing again is the fix. + SessionRejected, } -/// Connects and stays connected until cancelled, reporting through `status`. +/// Connects and stays connected until cancelled, reporting through `status`. A saved +/// (`Credential::Bearer`) session that the server rejects ends it instead of retrying forever. pub async fn run( http_base: &str, credential: &Credential, environment: &Environment, actions: &ActionReceiver, status: &StatusSender, -) { +) -> Ended { let client = auth::http_client(); let mut bearer: Option = None; // Outlives a socket: a reconnect subscribes to the thread again. @@ -319,7 +308,8 @@ pub async fn run( ); failure } - Err(message) => message, + Err(ConnectError::SessionRejected) => return Ended::SessionRejected, + Err(ConnectError::Failed(message)) => message, }; publish(status, Status::Failed(format!("Reconnecting: {failure}"))); sleep(backoff.next_delay()).await; @@ -329,12 +319,23 @@ pub async fn run( type Socket = tokio_tungstenite::WebSocketStream>; +enum ConnectError { + SessionRejected, + Failed(String), +} + +impl From for ConnectError { + fn from(message: String) -> Self { + Self::Failed(message) + } +} + async fn connect( client: &reqwest::Client, http_base: &str, credential: &Credential, bearer: &mut Option, -) -> Result { +) -> Result { let token = match bearer { Some(token) => token.clone(), None => { @@ -345,29 +346,20 @@ async fn connect( token } }; - let ticket = match auth::issue_ws_ticket(client, http_base, credential, &token).await { + let ticket = match auth::issue_ws_ticket(client, http_base, &token).await { Ok(ticket) => ticket, Err(error) => { if matches!(error, AuthError::Rejected(_)) { *bearer = None; } - return Err(error.to_string()); + let saved = matches!(credential, Credential::Bearer(_)); + if saved && matches!(error, AuthError::Rejected(401)) { + return Err(ConnectError::SessionRejected); + } + return Err(ConnectError::Failed(error.to_string())); } }; - let mut url = socket_url(http_base, &ticket)?; - // TODO(gpui-credentials): HACK. Reads Electron's private IndexedDB and non-extractable DPoP key. Replace with GPUI's own pairing + Keychain before shipping. - if let Credential::Dpop { key, .. } = credential { - // A DPoP ticket is key-bound: the upgrade carries a proof for the socket URL in `dpop`, - // with the ticket as the token it hashes (remote.ts resolveRemoteDpopWebSocketConnectionUrl). - let proof = electron_dpop::create_proof( - key, - "GET", - &upgrade_htu(&url), - Some(&ticket), - auth::now_ms(), - )?; - url.query_pairs_mut().append_pair("dpop", &proof); - } + let url = socket_url(http_base, &ticket)?; // Browsers cap neither frames nor messages, and shell snapshots outgrow tungstenite's 16 MiB default. let config = WebSocketConfig::default() .max_frame_size(None) @@ -375,7 +367,7 @@ async fn connect( let (socket, _) = tokio_tungstenite::connect_async_with_config(url.as_str(), Some(config), false) .await - .map_err(|error| error.to_string())?; + .map_err(|error| ConnectError::Failed(error.to_string()))?; Ok(socket) } diff --git a/apps/desktop-gpui/src/credentials.rs b/apps/desktop-gpui/src/credentials.rs new file mode 100644 index 000000000000..cce7f4bd262d --- /dev/null +++ b/apps/desktop-gpui/src/credentials.rs @@ -0,0 +1,272 @@ +//! The session this app holds for each environment it paired with, kept in the macOS Keychain. +//! +//! The server exchanges a one-time pairing token for a bearer session that lasts 30 days +//! (`DEFAULT_SESSION_TTL`, `apps/server/src/auth/SessionStore.ts`), and the same session is how +//! the web and Electron clients stay connected to a direct environment. There is no refresh +//! token: a revoked or expired session means pairing again. A DPoP-bound token is not an option +//! here, because it lives one hour and only another one-time credential can mint the next. + +use serde::{Deserialize, Serialize}; + +/// Keychain service every item is stored under; the account is the environment id. +#[cfg(target_os = "macos")] +const SERVICE: &str = "T3 Code (GPUI) Environment Sessions"; + +/// What `exchange_bearer` saved for one environment. The token is a secret: `Debug` hides it. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct StoredCredential { + pub environment_id: String, + pub label: String, + pub http_base: String, + pub token: String, + pub saved_at_ms: u64, + /// When the server says the session ends; informational, the server decides. + #[serde(default)] + pub expires_at_ms: Option, +} + +impl std::fmt::Debug for StoredCredential { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("StoredCredential") + .field("environment_id", &self.environment_id) + .field("label", &self.label) + .field("http_base", &self.http_base) + .field("token", &"") + .field("saved_at_ms", &self.saved_at_ms) + .field("expires_at_ms", &self.expires_at_ms) + .finish() + } +} + +impl StoredCredential { + pub fn encode(&self) -> Result, String> { + serde_json::to_vec(self) + .map_err(|error| format!("could not encode the credential: {error}")) + } + + pub fn decode(bytes: &[u8]) -> Result { + serde_json::from_slice(bytes).map_err(|_| "the stored credential is unreadable".to_owned()) + } +} + +/// Where credentials live. Blocking: the Keychain may prompt, so call from `spawn_blocking`. +pub trait CredentialStore: Send + Sync { + fn load(&self, environment_id: &str) -> Result, String>; + fn save(&self, credential: &StoredCredential) -> Result<(), String>; + /// Deleting what is not there succeeds: the reverse state must be idempotent. + fn delete(&self, environment_id: &str) -> Result<(), String>; + /// Every environment this app is paired with; unreadable entries are skipped. + fn list(&self) -> Result, String>; +} + +#[cfg(target_os = "macos")] +pub use keychain::Keychain; + +#[cfg(target_os = "macos")] +mod keychain { + use super::{CredentialStore, SERVICE, StoredCredential}; + use security_framework::item::{ItemClass, ItemSearchOptions, Limit}; + use security_framework::passwords::{ + delete_generic_password, get_generic_password, set_generic_password, + }; + + /// `errSecItemNotFound`. + const NOT_FOUND: i32 = -25300; + + pub struct Keychain; + + fn describe(error: security_framework::base::Error) -> String { + format!("Keychain: {error}") + } + + impl CredentialStore for Keychain { + fn load(&self, environment_id: &str) -> Result, String> { + match get_generic_password(SERVICE, environment_id) { + Ok(bytes) => StoredCredential::decode(&bytes).map(Some), + Err(error) if error.code() == NOT_FOUND => Ok(None), + Err(error) => Err(describe(error)), + } + } + + fn save(&self, credential: &StoredCredential) -> Result<(), String> { + set_generic_password(SERVICE, &credential.environment_id, &credential.encode()?) + .map_err(describe) + } + + fn delete(&self, environment_id: &str) -> Result<(), String> { + match delete_generic_password(SERVICE, environment_id) { + Ok(()) => Ok(()), + Err(error) if error.code() == NOT_FOUND => Ok(()), + Err(error) => Err(describe(error)), + } + } + + fn list(&self) -> Result, String> { + let found = ItemSearchOptions::new() + .class(ItemClass::generic_password()) + .service(SERVICE) + .load_attributes(true) + .limit(Limit::All) + .search(); + let results = match found { + Ok(results) => results, + Err(error) if error.code() == NOT_FOUND => return Ok(Vec::new()), + Err(error) => return Err(describe(error)), + }; + let mut credentials = Vec::new(); + for result in results { + let Some(account) = result + .simplify_dict() + .and_then(|attributes| attributes.get("acct").cloned()) + else { + continue; + }; + if let Ok(Some(credential)) = self.load(&account) { + credentials.push(credential); + } + } + Ok(credentials) + } + } +} + +/// No Keychain here: every call says so, and the app asks for pairing on each launch. +#[cfg(not(target_os = "macos"))] +pub struct Keychain; + +#[cfg(not(target_os = "macos"))] +impl CredentialStore for Keychain { + fn load(&self, _: &str) -> Result, String> { + Err("saved pairings need the macOS Keychain".to_owned()) + } + fn save(&self, _: &StoredCredential) -> Result<(), String> { + Err("saved pairings need the macOS Keychain".to_owned()) + } + fn delete(&self, _: &str) -> Result<(), String> { + Ok(()) + } + fn list(&self) -> Result, String> { + Ok(Vec::new()) + } +} + +#[cfg(test)] +pub use memory::MemoryStore; + +#[cfg(test)] +mod memory { + use super::{CredentialStore, StoredCredential}; + use std::collections::BTreeMap; + use std::sync::Mutex; + + /// Round-trips through the encoded form so tests exercise (de)serialization too. + #[derive(Default)] + pub struct MemoryStore(Mutex>>); + + impl CredentialStore for MemoryStore { + fn load(&self, id: &str) -> Result, String> { + let items = self.0.lock().expect("store lock"); + items + .get(id) + .map(|bytes| StoredCredential::decode(bytes)) + .transpose() + } + fn save(&self, credential: &StoredCredential) -> Result<(), String> { + let bytes = credential.encode()?; + self.0 + .lock() + .expect("store lock") + .insert(credential.environment_id.clone(), bytes); + Ok(()) + } + fn delete(&self, id: &str) -> Result<(), String> { + self.0.lock().expect("store lock").remove(id); + Ok(()) + } + fn list(&self) -> Result, String> { + let items = self.0.lock().expect("store lock"); + Ok(items + .values() + .filter_map(|bytes| StoredCredential::decode(bytes).ok()) + .collect()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(id: &str) -> StoredCredential { + StoredCredential { + environment_id: id.to_owned(), + label: "Studio".to_owned(), + http_base: "http://127.0.0.1:3773".to_owned(), + token: "secret-token".to_owned(), + saved_at_ms: 10, + expires_at_ms: Some(20), + } + } + + #[test] + fn round_trips_through_json() { + let credential = sample("env-1"); + assert_eq!( + StoredCredential::decode(&credential.encode().expect("encode")), + Ok(credential) + ); + } + + #[test] + fn old_entries_without_an_expiry_still_decode() { + let json = + br#"{"environment_id":"e","label":"l","http_base":"h","token":"t","saved_at_ms":1}"#; + assert_eq!( + StoredCredential::decode(json) + .expect("decode") + .expires_at_ms, + None + ); + } + + #[test] + fn garbage_is_an_error_that_does_not_echo_the_bytes() { + let error = StoredCredential::decode(b"not json secret").expect_err("garbage"); + assert!(!error.contains("secret")); + } + + #[test] + fn debug_hides_the_token() { + assert!(!format!("{:?}", sample("e")).contains("secret-token")); + } + + #[test] + fn memory_store_saves_lists_and_deletes_idempotently() { + let store = MemoryStore::default(); + assert_eq!(store.load("a"), Ok(None)); + store.save(&sample("a")).expect("save"); + store.save(&sample("b")).expect("save"); + assert_eq!(store.load("a"), Ok(Some(sample("a")))); + assert_eq!(store.list().expect("list").len(), 2); + store.delete("a").expect("delete"); + store.delete("a").expect("delete again"); + assert_eq!(store.load("a"), Ok(None)); + assert_eq!(store.list().expect("list"), vec![sample("b")]); + } + + /// Talks to the real login Keychain: `cargo test -- --ignored keychain`. + #[cfg(target_os = "macos")] + #[test] + #[ignore = "touches the login Keychain"] + fn keychain_saves_lists_and_deletes() { + let id = format!("smoke-test-{}", std::process::id()); + let store = Keychain; + store.save(&sample(&id)).expect("save"); + assert_eq!(store.load(&id), Ok(Some(sample(&id)))); + assert!(store.list().expect("list").contains(&sample(&id))); + store.delete(&id).expect("delete"); + store.delete(&id).expect("delete again"); + assert_eq!(store.load(&id), Ok(None)); + assert!(!store.list().expect("list").contains(&sample(&id))); + } +} diff --git a/apps/desktop-gpui/src/electron_dpop.rs b/apps/desktop-gpui/src/electron_dpop.rs deleted file mode 100644 index b83c293a3b95..000000000000 --- a/apps/desktop-gpui/src/electron_dpop.rs +++ /dev/null @@ -1,720 +0,0 @@ -// TODO(gpui-credentials): HACK. Reads Electron's private IndexedDB and non-extractable DPoP key. Replace with GPUI's own pairing + Keychain before shipping. -// -//! The Electron app's DPoP proof key, and RFC 9449 proofs signed with it. -//! -//! apps/web/src/cloud/dpop.ts keeps the key as `{privateKey: CryptoKey, publicJwk, thumbprint}` in -//! IndexedDB (`t3code:cloud-auth` / `keys` / `relay-dpop-proof-key`). Chromium stores that in a -//! LevelDB directory as a Blink structured clone, where an EC CryptoKey is the `K` tag followed by -//! its algorithm, curve, usages and PKCS8 bytes. This module copies the directory to a temp dir, -//! scans the table and log files for the record (no LevelDB crate: the `idb_cmp1` comparator is -//! irrelevant to a scan), and decodes the key. Claims mirror -//! packages/client-runtime/src/relay/dpopProof.ts and packages/shared/src/dpopCommon.ts. -//! Nothing here logs or returns key material beyond the `SigningKey` itself. - -use p256::ecdsa::{Signature, SigningKey, signature::Signer}; -use p256::pkcs8::DecodePrivateKey; -use serde_json::json; -use sha2::{Digest, Sha256}; -use std::collections::HashMap; -use std::path::{Path, PathBuf}; - -/// Where Electron's dev app keeps IndexedDB for the `t3code-dev` app. -pub const ENV_IDB_DIR: &str = "T3CODE_GPUI_ELECTRON_IDB"; -const KEY_ID: &str = "relay-dpop-proof-key"; - -pub fn default_idb_dir() -> Option { - if let Some(dir) = std::env::var_os(ENV_IDB_DIR).filter(|dir| !dir.is_empty()) { - return Some(dir.into()); - } - let home = std::env::var_os("HOME")?; - Some( - Path::new(&home) - .join("Library/Application Support/t3code-dev/IndexedDB") - .join("t3code-dev_app_0.indexeddb.leveldb"), - ) -} - -// --------------------------------------------------------------------------------------------- -// base64url (RFC 4648 section 5, no padding) - -pub fn b64url_encode(input: &[u8]) -> String { - const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; - let mut out = String::with_capacity(input.len().div_ceil(3) * 4); - for chunk in input.chunks(3) { - let n = chunk - .iter() - .enumerate() - .fold(0u32, |n, (i, b)| n | u32::from(*b) << (16 - 8 * i)); - for i in 0..=chunk.len() { - out.push(ALPHABET[(n >> (18 - 6 * i) & 63) as usize] as char); - } - } - out -} - -// --------------------------------------------------------------------------------------------- -// Proof signing - -/// The key's public JWK members, in RFC 7638 order. -fn public_jwk(key: &SigningKey) -> serde_json::Value { - let point = key.verifying_key().to_sec1_point(false); - let (x, y) = ( - point.x().expect("uncompressed point has x"), - point.y().expect("uncompressed point has y"), - ); - json!({"crv": "P-256", "kty": "EC", "x": b64url_encode(x), "y": b64url_encode(y)}) -} - -/// RFC 7638 thumbprint, as stored in `RemoteDpopAccessToken.dpopThumbprint`. -pub fn thumbprint(key: &SigningKey) -> String { - // serde_json's map is sorted, which is the lexicographic order the RFC asks for. - b64url_encode(&Sha256::digest(public_jwk(key).to_string().as_bytes())) -} - -/// `normalizeDpopHtu`: the URL without query or fragment. -pub fn normalize_htu(url: &str) -> Result { - let mut parsed = reqwest::Url::parse(url).map_err(|_| "DPoP URL is invalid.".to_owned())?; - parsed.set_fragment(None); - parsed.set_query(None); - Ok(parsed.to_string()) -} - -fn random_uuid_v4() -> Result { - let mut bytes = [0u8; 16]; - getrandom::fill(&mut bytes).map_err(|error| format!("could not read random bytes: {error}"))?; - bytes[6] = bytes[6] & 0x0f | 0x40; - bytes[8] = bytes[8] & 0x3f | 0x80; - let hex = hex::encode(bytes); - Ok(format!( - "{}-{}-{}-{}-{}", - &hex[..8], - &hex[8..12], - &hex[12..16], - &hex[16..20], - &hex[20..] - )) -} - -/// `createProof`: an ES256 JWS with `typ: dpop+jwt` and the public JWK, bound to the request -/// method and URL, and to `access_token` through `ath` when there is one. -pub fn create_proof( - key: &SigningKey, - method: &str, - url: &str, - access_token: Option<&str>, - now_ms: u64, -) -> Result { - let header = json!({"typ": "dpop+jwt", "alg": "ES256", "jwk": public_jwk(key)}); - let mut claims = json!({ - "htm": method.to_uppercase(), - "htu": normalize_htu(url)?, - "jti": random_uuid_v4()?, - "iat": now_ms / 1_000, - }); - if let Some(token) = access_token.filter(|token| !token.is_empty()) { - claims["ath"] = json!(b64url_encode(&Sha256::digest(token.as_bytes()))); - } - let signing_input = format!( - "{}.{}", - b64url_encode(header.to_string().as_bytes()), - b64url_encode(claims.to_string().as_bytes()) - ); - let signature: Signature = key.sign(signing_input.as_bytes()); - Ok(format!( - "{signing_input}.{}", - b64url_encode(&signature.to_bytes()) - )) -} - -// --------------------------------------------------------------------------------------------- -// Blink CryptoKey - -/// V8 `ValueSerializer` varint (base-128, little endian). -fn read_varint(bytes: &[u8], at: &mut usize) -> Option { - let (mut value, mut shift) = (0u64, 0u32); - loop { - let byte = *bytes.get(*at)?; - *at += 1; - if shift >= 64 { - return None; - } - value |= u64::from(byte & 0x7f) << shift; - if byte & 0x80 == 0 { - return Some(value); - } - shift += 7; - } -} - -const CRYPTO_KEY_TAG: u8 = b'K'; -const EC_KEY_TAG: u8 = 5; -const PRIVATE_KEY_TYPE: u64 = 2; -const P256_CURVE_TAG: u64 = 1; - -/// Decodes the EC private `CryptoKey` that starts at `bytes[0]` (`V8ScriptValueSerializerForModules` -/// layout: `K`, EC sub-tag, algorithm, curve, key type, usages, length, PKCS8 bytes). -fn decode_crypto_key_at(bytes: &[u8]) -> Option { - let mut at = 0; - if bytes.get(at)? != &CRYPTO_KEY_TAG || bytes.get(at + 1)? != &EC_KEY_TAG { - return None; - } - at += 2; - let _algorithm = read_varint(bytes, &mut at)?; - if read_varint(bytes, &mut at)? != P256_CURVE_TAG { - return None; - } - if read_varint(bytes, &mut at)? != PRIVATE_KEY_TYPE { - return None; - } - let _usages = read_varint(bytes, &mut at)?; - let length = usize::try_from(read_varint(bytes, &mut at)?).ok()?; - let pkcs8 = bytes.get(at..at.checked_add(length)?)?; - SigningKey::from_pkcs8_der(pkcs8).ok() -} - -/// Finds the stored private CryptoKey anywhere in an IndexedDB record value. The surrounding -/// object (`publicJwk`, `thumbprint`) is skipped rather than parsed: the key's tag bytes are -/// validated by their PKCS8 payload, so a false hit cannot decode. -pub fn decode_crypto_key(value: &[u8]) -> Option { - (0..value.len()) - .filter(|&at| value[at] == CRYPTO_KEY_TAG) - .find_map(|at| decode_crypto_key_at(&value[at..])) -} - -/// A record value may be wrapped in Chromium's snappy envelope (tag 0x11 after the version). -fn key_from_record(value: &[u8]) -> Option { - decode_crypto_key(value).or_else(|| { - (0..value.len().min(16)) - .filter(|&at| value[at] == 0x11) - .find_map(|at| { - let inflated = snap::raw::Decoder::new() - .decompress_vec(&value[at + 1..]) - .ok()?; - decode_crypto_key(&inflated) - }) - }) -} - -// --------------------------------------------------------------------------------------------- -// LevelDB scan - -struct Record { - sequence: u64, - deleted: bool, - value: Vec, -} - -/// IndexedDB's key for `relay-dpop-proof-key` in an object store: a prefix (database, store and -/// index ids; index 1 is the object store data) and the string key (`0x01`, length, UTF-16BE). -fn is_key_record(user_key: &[u8]) -> bool { - let Some(&flags) = user_key.first() else { - return false; - }; - let db_bytes = usize::from(flags >> 5 & 7) + 1; - let store_bytes = usize::from(flags >> 2 & 7) + 1; - let index_bytes = usize::from(flags & 3) + 1; - let prefix = 1 + db_bytes + store_bytes + index_bytes; - let Some(index) = user_key.get(prefix - index_bytes..prefix) else { - return false; - }; - let index_id = index - .iter() - .enumerate() - .fold(0u64, |id, (i, byte)| id | u64::from(*byte) << (8 * i)); - let mut expected = vec![0x01, KEY_ID.len() as u8]; - expected.extend(KEY_ID.encode_utf16().flat_map(u16::to_be_bytes)); - index_id == 1 && user_key.get(prefix..) == Some(expected.as_slice()) -} - -fn read_u32_le(bytes: &[u8], at: usize) -> Option { - Some(u32::from_le_bytes(bytes.get(at..at + 4)?.try_into().ok()?)) -} - -fn read_slice<'a>(bytes: &'a [u8], at: &mut usize) -> Option<&'a [u8]> { - let length = usize::try_from(read_varint(bytes, at)?).ok()?; - let slice = bytes.get(*at..at.checked_add(length)?)?; - *at += length; - Some(slice) -} - -fn keep_newest(found: &mut HashMap, Record>, user_key: &[u8], record: Record) { - if !is_key_record(user_key) { - return; - } - match found.get(user_key) { - Some(existing) if existing.sequence >= record.sequence => {} - _ => { - found.insert(user_key.to_vec(), record); - } - } -} - -/// `.log` files: 32 KiB blocks of `crc(4) length(2) type(1)` fragments that reassemble into -/// WriteBatches (`sequence(8) count(4)` then `type key [value]` entries). -fn scan_log(data: &[u8], found: &mut HashMap, Record>) { - const BLOCK: usize = 32 * 1024; - let mut batch = Vec::new(); - let mut batches = Vec::new(); - for block in data.chunks(BLOCK) { - let mut at = 0; - while at + 7 <= block.len() { - let length = usize::from(u16::from_le_bytes([block[at + 4], block[at + 5]])); - let kind = block[at + 6]; - let Some(payload) = block.get(at + 7..at + 7 + length) else { - break; - }; - at += 7 + length; - match kind { - 1 => batches.push(payload.to_vec()), - 2 => batch = payload.to_vec(), - 3 => batch.extend_from_slice(payload), - 4 => { - batch.extend_from_slice(payload); - batches.push(std::mem::take(&mut batch)); - } - _ => {} - } - } - } - for batch in batches { - let Some(sequence) = batch - .get(..8) - .map(|bytes| u64::from_le_bytes(bytes.try_into().expect("8 bytes"))) - else { - continue; - }; - let Some(count) = read_u32_le(&batch, 8) else { - continue; - }; - let mut at = 12; - for index in 0..u64::from(count) { - let Some(&kind) = batch.get(at) else { break }; - at += 1; - let Some(key) = read_slice(&batch, &mut at) else { - break; - }; - let value = if kind == 1 { - match read_slice(&batch, &mut at) { - Some(value) => value, - None => break, - } - } else { - &[] - }; - keep_newest( - found, - key, - Record { - sequence: sequence + index, - deleted: kind == 0, - value: value.to_vec(), - }, - ); - } - } -} - -const TABLE_MAGIC: [u8; 8] = [0x57, 0xfb, 0x80, 0x8b, 0x24, 0x75, 0x47, 0xdb]; - -fn read_handle(bytes: &[u8], at: &mut usize) -> Option<(usize, usize)> { - let offset = usize::try_from(read_varint(bytes, at)?).ok()?; - let size = usize::try_from(read_varint(bytes, at)?).ok()?; - Some((offset, size)) -} - -/// A table block's contents, snappy-inflated when its trailer says so. -fn read_block(data: &[u8], (offset, size): (usize, usize)) -> Option> { - let contents = data.get(offset..offset.checked_add(size)?)?; - match data.get(offset + size)? { - 0 => Some(contents.to_vec()), - 1 => snap::raw::Decoder::new().decompress_vec(contents).ok(), - _ => None, - } -} - -/// The `(key, value)` entries of a block (prefix-compressed, restart array at the end). -fn block_entries(block: &[u8]) -> Vec<(Vec, &[u8])> { - let mut entries = Vec::new(); - let Some(restarts) = read_u32_le(block, block.len().saturating_sub(4)) else { - return entries; - }; - let Some(end) = block.len().checked_sub(4 + 4 * restarts as usize) else { - return entries; - }; - let (mut at, mut key) = (0, Vec::new()); - while at < end { - let (Some(shared), Some(unshared), Some(value_length)) = ( - read_varint(block, &mut at), - read_varint(block, &mut at), - read_varint(block, &mut at), - ) else { - break; - }; - let (shared, unshared, value_length) = - (shared as usize, unshared as usize, value_length as usize); - let (Some(delta), Some(value)) = ( - block.get(at..at.saturating_add(unshared)), - block.get(at + unshared..(at + unshared).saturating_add(value_length)), - ) else { - break; - }; - if shared > key.len() { - break; - } - key.truncate(shared); - key.extend_from_slice(delta); - at += unshared + value_length; - entries.push((key.clone(), value)); - } - entries -} - -/// `.ldb` files: footer, index block of data block handles, data blocks of internal keys -/// (`user key` + `sequence << 8 | type`). -fn scan_table(data: &[u8], found: &mut HashMap, Record>) { - let Some(footer) = data.len().checked_sub(48).map(|start| &data[start..]) else { - return; - }; - if footer[40..] != TABLE_MAGIC { - return; - } - let mut at = 0; - if read_handle(footer, &mut at).is_none() { - return; - } - let Some(index_handle) = read_handle(footer, &mut at) else { - return; - }; - let Some(index) = read_block(data, index_handle) else { - return; - }; - for (_, handle) in block_entries(&index) { - let mut at = 0; - let Some(block) = read_handle(handle, &mut at).and_then(|h| read_block(data, h)) else { - continue; - }; - for (internal_key, value) in block_entries(&block) { - let Some(split) = internal_key.len().checked_sub(8) else { - continue; - }; - let trailer = u64::from_le_bytes(internal_key[split..].try_into().expect("8 bytes")); - keep_newest( - found, - &internal_key[..split], - Record { - sequence: trailer >> 8, - deleted: trailer & 0xff == 0, - value: value.to_vec(), - }, - ); - } - } -} - -/// Removes the temp copy on every exit path. -struct TempCopy(PathBuf); - -impl Drop for TempCopy { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -fn scan_directory(dir: &Path) -> Result, String> { - let mut found = HashMap::new(); - let entries = std::fs::read_dir(dir).map_err(|error| error.to_string())?; - for entry in entries.flatten() { - let path = entry.path(); - let Ok(data) = std::fs::read(&path) else { - continue; - }; - match path.extension().and_then(|extension| extension.to_str()) { - Some("ldb") | Some("sst") => scan_table(&data, &mut found), - Some("log") => scan_log(&data, &mut found), - _ => {} - } - } - Ok(found.into_values().filter(|r| !r.deleted).collect()) -} - -/// Reads the Electron app's DPoP proof key from its IndexedDB directory. Only a copy of the -/// `.ldb` and `.log` files is read, and the copy is deleted before this returns. -pub fn load_key(idb_dir: &Path) -> Result { - let mut suffix = [0u8; 8]; - getrandom::fill(&mut suffix).map_err(|error| error.to_string())?; - let copy = TempCopy(std::env::temp_dir().join(format!("t3-gpui-idb-{}", hex::encode(suffix)))); - std::fs::create_dir(©.0).map_err(|error| error.to_string())?; - let entries = std::fs::read_dir(idb_dir) - .map_err(|error| format!("could not read the Electron IndexedDB: {error}"))?; - for entry in entries.flatten() { - let path = entry.path(); - if matches!( - path.extension().and_then(|extension| extension.to_str()), - Some("ldb") | Some("sst") | Some("log") - ) && let Some(name) = path.file_name() - { - std::fs::copy(&path, copy.0.join(name)).map_err(|error| error.to_string())?; - } - } - let mut records = scan_directory(©.0)?; - records.sort_by_key(|record| std::cmp::Reverse(record.sequence)); - records - .iter() - .find_map(|record| key_from_record(&record.value)) - .ok_or_else(|| "no DPoP proof key in the Electron IndexedDB".to_owned()) -} - -#[cfg(test)] -mod tests { - use super::*; - use p256::pkcs8::EncodePrivateKey; - - fn varint(mut value: u64) -> Vec { - let mut out = Vec::new(); - loop { - let byte = (value & 0x7f) as u8; - value >>= 7; - if value == 0 { - out.push(byte); - return out; - } - out.push(byte | 0x80); - } - } - - fn fresh_key() -> SigningKey { - let mut seed = [0u8; 32]; - getrandom::fill(&mut seed).unwrap(); - SigningKey::from_slice(&seed).unwrap() - } - - /// What Blink writes for an extractable-false ECDSA P-256 private key with the `sign` usage. - fn serialized_crypto_key(key: &SigningKey) -> Vec { - let pkcs8 = key.to_pkcs8_der().unwrap(); - let mut out = vec![CRYPTO_KEY_TAG, EC_KEY_TAG]; - out.extend(varint(13)); // ECDSA - out.extend(varint(P256_CURVE_TAG)); - out.extend(varint(PRIVATE_KEY_TYPE)); - out.extend(varint(1 << 3)); // sign - out.extend(varint(pkcs8.as_bytes().len() as u64)); - out.extend_from_slice(pkcs8.as_bytes()); - out - } - - /// An IndexedDB value: version, wrapper bytes, then an object whose first property is the key. - fn record_value(key: &SigningKey) -> Vec { - let mut value = vec![0x00, 0xff, 0x11, 0xff, 0x0f, b'o', b'"', 10]; - value.extend_from_slice(b"privateKey"); - value.extend(serialized_crypto_key(key)); - value.extend_from_slice(b"\"\x09publicJwk{"); - value - } - - #[test] - fn decodes_a_blink_serialized_key_inside_a_record() { - let key = fresh_key(); - let decoded = decode_crypto_key(&record_value(&key)).expect("key"); - assert_eq!(thumbprint(&decoded), thumbprint(&key)); - assert!(decode_crypto_key(b"K\x05\x0d\x01\x02").is_none()); - assert!(decode_crypto_key(&[]).is_none()); - } - - #[test] - fn rejects_public_and_other_curve_keys() { - let mut other_curve = serialized_crypto_key(&fresh_key()); - other_curve[3] = 2; // P-384 - assert!(decode_crypto_key(&other_curve).is_none()); - let mut public = serialized_crypto_key(&fresh_key()); - public[4] = 1; - assert!(decode_crypto_key(&public).is_none()); - } - - #[test] - fn decodes_a_snappy_wrapped_record() { - let key = fresh_key(); - let mut value = vec![0x00, 0x11]; - value.extend( - snap::raw::Encoder::new() - .compress_vec(&record_value(&key)) - .unwrap(), - ); - // Without the envelope the compressed bytes hold no key, so the wrapper path is the one used. - let decoded = key_from_record(&value).expect("key"); - assert_eq!(thumbprint(&decoded), thumbprint(&key)); - } - - #[test] - fn recognises_the_idb_object_store_key() { - let mut key = vec![0x00, 0x01, 0x02, 0x01]; // 1-byte ids, db 1, store 2, index 1 - key.extend([0x01, KEY_ID.len() as u8]); - key.extend(KEY_ID.encode_utf16().flat_map(u16::to_be_bytes)); - assert!(is_key_record(&key)); - let mut other_index = key.clone(); - other_index[3] = 2; - assert!(!is_key_record(&other_index)); - assert!(!is_key_record(&key[..key.len() - 2])); - assert!(!is_key_record(&[])); - } - - #[test] - fn scans_a_leveldb_log_and_keeps_the_newest_value() { - let key = fresh_key(); - let mut user_key = vec![0x00, 0x01, 0x02, 0x01, 0x01, KEY_ID.len() as u8]; - user_key.extend(KEY_ID.encode_utf16().flat_map(u16::to_be_bytes)); - let batch = |sequence: u64, value: &[u8]| { - let mut batch = sequence.to_le_bytes().to_vec(); - batch.extend(1u32.to_le_bytes()); - batch.push(1); - batch.extend(varint(user_key.len() as u64)); - batch.extend(&user_key); - batch.extend(varint(value.len() as u64)); - batch.extend(value); - batch - }; - let mut log = Vec::new(); - for (sequence, value) in [(5, b"stale".to_vec()), (9, record_value(&key))] { - let payload = batch(sequence, &value); - log.extend([0u8; 4]); - log.extend((payload.len() as u16).to_le_bytes()); - log.push(1); - log.extend(payload); - } - let mut found = HashMap::new(); - scan_log(&log, &mut found); - let record = found.get(&user_key).expect("record"); - assert_eq!(record.sequence, 9); - let decoded = key_from_record(&record.value).expect("key"); - assert_eq!(thumbprint(&decoded), thumbprint(&key)); - } - - #[test] - fn scans_a_table_block_with_prefix_compression() { - let mut user_key = vec![0x00, 0x01, 0x02, 0x01, 0x01, KEY_ID.len() as u8]; - user_key.extend(KEY_ID.encode_utf16().flat_map(u16::to_be_bytes)); - let mut internal = user_key.clone(); - internal.extend(((7u64 << 8) | 1).to_le_bytes()); - let key = fresh_key(); - let value = record_value(&key); - // Two entries: a full key, then the same key sharing all but its trailer. - let mut block = Vec::new(); - block.extend(varint(0)); - block.extend(varint(internal.len() as u64)); - block.extend(varint(value.len() as u64)); - block.extend(&internal); - block.extend(&value); - let mut second = user_key.clone(); - second.extend(((8u64 << 8) | 1).to_le_bytes()); - block.extend(varint(user_key.len() as u64)); - block.extend(varint(8)); - block.extend(varint(1)); - block.extend(&second[user_key.len()..]); - block.push(0); - block.extend(0u32.to_le_bytes()); - block.extend(1u32.to_le_bytes()); - let entries = block_entries(&block); - assert_eq!(entries.len(), 2); - assert_eq!(entries[1].0, second); - - let mut table = Vec::new(); - let data_handle = (table.len(), block.len()); - table.extend(&block); - table.extend([0, 0, 0, 0, 0]); - let mut index = Vec::new(); - let mut handle = varint(data_handle.0 as u64); - handle.extend(varint(data_handle.1 as u64)); - index.extend(varint(0)); - index.extend(varint(second.len() as u64)); - index.extend(varint(handle.len() as u64)); - index.extend(&second); - index.extend(&handle); - index.extend(0u32.to_le_bytes()); - index.extend(1u32.to_le_bytes()); - let index_handle = (table.len(), index.len()); - table.extend(&index); - table.extend([0, 0, 0, 0, 0]); - let mut footer = varint(0); - footer.extend(varint(0)); - footer.extend(varint(index_handle.0 as u64)); - footer.extend(varint(index_handle.1 as u64)); - footer.resize(40, 0); - footer.extend(TABLE_MAGIC); - table.extend(footer); - - let mut found = HashMap::new(); - scan_table(&table, &mut found); - let record = found.get(&user_key).expect("record"); - assert_eq!(record.sequence, 8); - assert!(!record.deleted); - } - - #[test] - fn proof_has_the_client_runtime_shape_and_verifies() { - use p256::ecdsa::{VerifyingKey, signature::Verifier}; - let key = fresh_key(); - let proof = create_proof( - &key, - "post", - "https://Host.example:443/api/auth/websocket-ticket/rpc?x=1#frag", - Some("access"), - 1_700_000_123_456, - ) - .unwrap(); - let parts: Vec<&str> = proof.split('.').collect(); - assert_eq!(parts.len(), 3); - let decode = |part: &str| { - let mut padded = part.replace('-', "+").replace('_', "/"); - while !padded.len().is_multiple_of(4) { - padded.push('='); - } - crate::catalog::b64::decode(&padded).unwrap() - }; - let header: serde_json::Value = serde_json::from_slice(&decode(parts[0])).unwrap(); - let claims: serde_json::Value = serde_json::from_slice(&decode(parts[1])).unwrap(); - assert_eq!(header["typ"], "dpop+jwt"); - assert_eq!(header["alg"], "ES256"); - assert_eq!(header["jwk"], public_jwk(&key)); - assert!(header["jwk"].get("d").is_none()); - assert_eq!(claims["htm"], "POST"); - assert_eq!( - claims["htu"], - "https://host.example/api/auth/websocket-ticket/rpc" - ); - assert_eq!(claims["iat"], 1_700_000_123); - assert_eq!(claims["jti"].as_str().unwrap().len(), 36); - // SHA-256("access"), base64url. - assert_eq!( - claims["ath"], - "oFYf1knNtrqnhAVfBRuteW6gr-8X_KOCGVSd7rpOjBo".to_owned() - ); - let signature = Signature::from_slice(&decode(parts[2])).unwrap(); - VerifyingKey::from(&key) - .verify(format!("{}.{}", parts[0], parts[1]).as_bytes(), &signature) - .unwrap(); - let no_ath = create_proof(&key, "GET", "http://h/ws", None, 0).unwrap(); - assert!(!no_ath.contains("ath")); - assert!(create_proof(&key, "GET", "not a url", None, 0).is_err()); - } - - #[test] - fn thumbprint_matches_rfc_7638_over_the_public_jwk() { - let key = fresh_key(); - let jwk = public_jwk(&key); - let canonical = format!( - r#"{{"crv":"P-256","kty":"EC","x":"{}","y":"{}"}}"#, - jwk["x"].as_str().unwrap(), - jwk["y"].as_str().unwrap() - ); - assert_eq!( - thumbprint(&key), - b64url_encode(&Sha256::digest(canonical.as_bytes())) - ); - } - - #[test] - fn base64url_matches_known_vectors() { - assert_eq!(b64url_encode(b""), ""); - assert_eq!(b64url_encode(b"f"), "Zg"); - assert_eq!(b64url_encode(b"fo"), "Zm8"); - assert_eq!(b64url_encode(&[0xfb, 0xff]), "-_8"); - } -} diff --git a/apps/desktop-gpui/src/environments.rs b/apps/desktop-gpui/src/environments.rs index fe55b506010c..3bd873a46a20 100644 --- a/apps/desktop-gpui/src/environments.rs +++ b/apps/desktop-gpui/src/environments.rs @@ -1,41 +1,35 @@ -//! The user's other saved environments (the Electron app's catalog): one connection each, whose -//! projects the picker uses to show where else a project lives. - -use crate::auth; -use crate::catalog::{self, SavedEnvironment}; -use crate::connection; +use crate::auth::{self, Credential}; +use crate::connection::{self, Ended}; +use crate::credentials::{CredentialStore, StoredCredential}; +use crate::pairing; use crate::projects::EnvironmentProjects; use crate::server; use crate::status::{Status, StatusSender, publish}; -use std::path::PathBuf; +use std::sync::Arc; use tokio::time::sleep; -/// Loads the catalog and keeps a connection to each reachable environment other than `primary_id`. -/// Any failure costs only the badges, and is logged once. -pub async fn run(primary_id: String, path: PathBuf, status: StatusSender) { - let loaded = tokio::task::spawn_blocking(move || catalog::load(&path)).await; - let catalog = match loaded { - Ok(Ok(catalog)) => catalog, +/// Lists what the Keychain holds for environments other than `primary_id` and keeps a connection +/// to each reachable one. Any failure costs only the badges, and is logged once. +pub async fn run(primary_id: String, store: Arc, status: StatusSender) { + let listed = { + let store = store.clone(); + tokio::task::spawn_blocking(move || store.list()).await + }; + let saved = match listed { + Ok(Ok(saved)) => saved, Ok(Err(message)) => return eprintln!("[environments] no environment badges: {message}"), Err(error) => return eprintln!("[environments] no environment badges: {error}"), }; - for (label, kind) in &catalog.skipped { - eprintln!("[environments] skipping {label}: {kind} connections are not supported"); - } - for notice in &catalog.notices { - eprintln!("[environments] {notice}"); - } - for saved in catalog - .environments + for saved in saved .into_iter() .filter(|saved| saved.environment_id != primary_id) { - tokio::spawn(follow(saved, status.clone())); + tokio::spawn(follow(store.clone(), saved, status.clone())); } } /// Mirrors one environment's projects into `status` for as long as the app runs. -async fn follow(saved: SavedEnvironment, status: StatusSender) { +async fn follow(store: Arc, saved: StoredCredential, status: StatusSender) { let client = auth::http_client(); let (own_tx, own_rx) = async_channel::unbounded(); // The connection ends its session when the action channel closes, so keep the sender here. @@ -81,12 +75,25 @@ async fn follow(saved: SavedEnvironment, status: StatusSender) { } } }; - let session = connection::run( - &saved.http_base, - &saved.credential, - &connection, - &actions_rx, - &own_tx, - ); + let session = async { + let credential = Credential::Bearer(saved.token.clone()); + let ended = connection::run( + &saved.http_base, + &credential, + &connection, + &actions_rx, + &own_tx, + ) + .await; + let Ended::SessionRejected = ended; + // The session is dead for good, so forget it; it shows again once paired. + let _ = pairing::forget(&store, &saved.environment_id).await; + eprintln!( + "[environments] {} rejected its saved pairing; forgot it", + saved.label + ); + // Close the channel so `forward` ends and the badges clear. + own_tx.close(); + }; tokio::join!(forward, session); } diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 511ea11fc8a1..ddaecccb42dc 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -1,17 +1,17 @@ mod assets; mod auth; mod branch_picker; -mod catalog; mod config; mod connection; +mod credentials; mod details; mod details_card; mod diff; -mod electron_dpop; mod environments; mod git_actions; mod history; mod minimap; +mod pairing; mod projects; mod providers; mod ref_picker; @@ -44,6 +44,7 @@ use gpui_platform::application; use server::Supervisor; use status::Status; use std::rc::Rc; +use std::sync::Arc; use ui::MainView; fn main() { @@ -72,23 +73,16 @@ fn main() { let client = auth::http_client(); match server::wait_until_ready(&client, &attach.http_base).await { Ok(environment) => { - // Only an attached server shares the Electron app's home, hence its catalog. - if let Some(path) = config::connection_catalog_path() { - tokio::spawn(environments::run( - environment.id.clone(), - path, - status_tx.clone(), - )); - } - let credential = auth::Credential::Pairing(attach.pairing_token); - connection::run( - &attach.http_base, - &credential, - &environment, - &action_rx, - &status_tx, - ) - .await; + let store: Arc = + Arc::new(credentials::Keychain); + // The badges follow the other environments this app has paired with. + tokio::spawn(environments::run( + environment.id.clone(), + store.clone(), + status_tx.clone(), + )); + let unpair = std::env::args().any(|arg| arg == "--unpair"); + pairing::run(store, attach, &environment, unpair, &action_rx, &status_tx).await; } Err(message) => status::publish(&status_tx, Status::Failed(message)), } diff --git a/apps/desktop-gpui/src/pairing.rs b/apps/desktop-gpui/src/pairing.rs new file mode 100644 index 000000000000..4a2c0f75a965 --- /dev/null +++ b/apps/desktop-gpui/src/pairing.rs @@ -0,0 +1,212 @@ +//! Attaching to a server: use the session the Keychain kept, else pair once with a token. + +use crate::auth::{self, AuthError, Credential}; +use crate::config::Attach; +use crate::connection::{self, Action, Ended, Environment}; +use crate::credentials::{CredentialStore, StoredCredential}; +use crate::status::{Status, StatusSender, publish}; +use async_channel::Receiver; +use std::sync::Arc; + +/// What to do with what is saved and what the launch handed over. +#[derive(Debug, PartialEq, Eq)] +pub enum Step { + UseSaved(StoredCredential), + Pair(String), + NeedsPairing, +} + +/// A saved session wins: the launch's token is typically the same one on every start and works +/// once, so it is only for an environment with nothing saved (or whose saved session was just dropped). +pub fn next_step(saved: Option, pairing_token: Option) -> Step { + match (saved, pairing_token) { + (Some(saved), _) => Step::UseSaved(saved), + (None, Some(token)) => Step::Pair(token), + (None, None) => Step::NeedsPairing, + } +} + +fn needs_pairing_message(label: &str, after_rejection: bool) -> String { + let lead = if after_rejection { + format!("The saved pairing with {label} was rejected (revoked or expired).") + } else { + format!("Not paired with {label}.") + }; + format!( + "{lead} Mint a token with `t3 pair` and relaunch with {} set.", + crate::config::ENV_PAIRING_TOKEN + ) +} + +async fn blocking( + store: &Arc, + work: impl FnOnce(&dyn CredentialStore) -> T + Send + 'static, +) -> Result { + let store = store.clone(); + tokio::task::spawn_blocking(move || work(store.as_ref())) + .await + .map_err(|error| error.to_string()) +} + +/// Forgets what is saved for `environment_id`. Deleting what is absent is fine. +pub async fn forget(store: &Arc, environment_id: &str) -> Result<(), String> { + let id = environment_id.to_owned(); + blocking(store, move |store| store.delete(&id)).await? +} + +/// Connects to an attached server and stays connected, pairing first when nothing is saved. +/// Returns only when pairing is needed and cannot happen on its own. +pub async fn run( + store: Arc, + attach: Attach, + environment: &Environment, + unpair: bool, + actions: &Receiver, + status: &StatusSender, +) { + let id = environment.id.clone(); + if unpair { + match forget(&store, &id).await { + Ok(()) => eprintln!( + "[pairing] forgot the saved pairing with {}", + environment.label + ), + Err(message) => eprintln!("[pairing] could not forget it: {message}"), + } + } + let mut pairing_token = attach.pairing_token; + let mut paired: Option = None; + let mut rejected = false; + loop { + let saved = match paired.take() { + Some(credential) => Some(credential), + None => { + let load_id = id.clone(); + match blocking(&store, move |store| store.load(&load_id)).await { + Ok(Ok(saved)) => saved, + Ok(Err(message)) | Err(message) => { + eprintln!("[pairing] could not read the saved pairing: {message}"); + None + } + } + } + }; + match next_step(saved, pairing_token.clone()) { + Step::UseSaved(saved) => { + let credential = Credential::Bearer(saved.token); + let ended = + connection::run(&attach.http_base, &credential, environment, actions, status) + .await; + match ended { + Ended::SessionRejected => { + if let Err(message) = forget(&store, &id).await { + eprintln!("[pairing] could not delete the rejected pairing: {message}"); + } + rejected = true; + } + } + } + Step::Pair(token) => { + pairing_token = None; + let client = auth::http_client(); + let exchanged = auth::exchange_session( + &client, + &attach.http_base, + &Credential::Pairing(auth::pairing_token(&token)), + ) + .await; + match exchanged { + Ok(session) => { + let now = auth::now_ms(); + let credential = StoredCredential { + environment_id: id.clone(), + label: environment.label.clone(), + http_base: attach.http_base.clone(), + token: session.token, + saved_at_ms: now, + expires_at_ms: session.expires_in_secs.map(|s| now + s * 1000), + }; + let to_save = credential.clone(); + match blocking(&store, move |store| store.save(&to_save)).await { + Ok(Ok(())) => {} + Ok(Err(message)) | Err(message) => eprintln!( + "[pairing] paired, but could not save it (you will pair again next launch): {message}" + ), + } + paired = Some(credential); + } + Err(AuthError::Rejected(_)) => { + return publish( + status, + Status::Failed( + "The pairing token was rejected. It works only once and expires; mint a new one with `t3 pair`.".to_owned(), + ), + ); + } + Err(error) => { + return publish(status, Status::Failed(format!("Pairing failed: {error}"))); + } + } + } + Step::NeedsPairing => { + return publish( + status, + Status::Failed(needs_pairing_message(&environment.label, rejected)), + ); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn saved() -> StoredCredential { + StoredCredential { + environment_id: "e".to_owned(), + label: "l".to_owned(), + http_base: "http://h".to_owned(), + token: "t".to_owned(), + saved_at_ms: 1, + expires_at_ms: None, + } + } + + #[test] + fn a_saved_session_wins_over_the_launch_token() { + assert_eq!( + next_step(Some(saved()), Some("tok".to_owned())), + Step::UseSaved(saved()) + ); + } + + #[test] + fn nothing_saved_pairs_with_the_token_or_asks_for_one() { + assert_eq!( + next_step(None, Some("tok".to_owned())), + Step::Pair("tok".to_owned()) + ); + assert_eq!(next_step(None, None), Step::NeedsPairing); + } + + #[test] + fn a_rejected_saved_session_falls_back_to_asking() { + // `run` deletes the entry on rejection and clears the launch token once it was tried, + // so the next decision sees nothing saved and no token. + assert_eq!(next_step(None, None), Step::NeedsPairing); + let message = needs_pairing_message("Studio", true); + assert!(message.contains("rejected")); + assert!(message.contains("t3 pair")); + assert!(needs_pairing_message("Studio", false).starts_with("Not paired with Studio.")); + } + + #[tokio::test] + async fn forgetting_removes_the_entry_and_is_idempotent() { + let store: Arc = Arc::new(crate::credentials::MemoryStore::default()); + store.save(&saved()).expect("save"); + forget(&store, "e").await.expect("forget"); + forget(&store, "e").await.expect("forget again"); + assert_eq!(store.load("e"), Ok(None)); + } +} From a2f0f2b1ba9b2663e9ca9714c00b216839a53412 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 22:05:41 -0500 Subject: [PATCH 46/82] feat(desktop-gpui): build composer send payloads --- apps/desktop-gpui/src/connection.rs | 17 +- apps/desktop-gpui/src/main.rs | 1 + apps/desktop-gpui/src/rpc.rs | 18 + apps/desktop-gpui/src/send.rs | 551 ++++++++++++++++++++++++++++ apps/desktop-gpui/src/strip.rs | 12 +- 5 files changed, 593 insertions(+), 6 deletions(-) create mode 100644 apps/desktop-gpui/src/send.rs diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 31be261f03b2..5002c8265ea5 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -6,10 +6,11 @@ use crate::diff; use crate::projects::MachineKind; use crate::providers::Catalog; use crate::rpc::{ - Event, GitRunStackedAction, PING_FRAME, ProjectsMutate, ReviewGetDiffPreview, ServerGetConfig, - Session, ShellOpenInEditor, SubscribeShell, SubscribeThread, SubscribeVcsStatus, - TerminalAttach, TerminalClose, TerminalResize, TerminalWrite, VcsCreateRef, VcsListRefs, - VcsPull, VcsRefreshStatus, VcsSwitchRef, + Event, GitRunStackedAction, OrchestrationDispatchCommand, OrchestrationLaunchThread, + PING_FRAME, ProjectsMutate, ReviewGetDiffPreview, ServerGetConfig, Session, ShellOpenInEditor, + SubscribeShell, SubscribeThread, SubscribeVcsStatus, TerminalAttach, TerminalClose, + TerminalResize, TerminalWrite, VcsCreateRef, VcsListRefs, VcsPull, VcsRefreshStatus, + VcsSwitchRef, }; use crate::shell::ShellState; use crate::status::{CallEvent, Status, StatusSender, publish}; @@ -107,6 +108,8 @@ pub enum Call { CreateRef, Pull, RefreshStatus, + LaunchThread, + DispatchCommand, /// A stream: progress events, then the result. RunStackedAction, } @@ -142,6 +145,8 @@ impl Call { Self::CreateRef => "create the ref", Self::Pull => "pull", Self::RefreshStatus => "refresh the status", + Self::LaunchThread => "start the thread", + Self::DispatchCommand => "send the command", Self::RunStackedAction => "run the action", } } @@ -157,6 +162,8 @@ impl Call { Self::CreateRef => session.request::(payload), Self::Pull => session.request::(payload), Self::RefreshStatus => session.request::(payload), + Self::LaunchThread => session.request::(payload), + Self::DispatchCommand => session.request::(payload), Self::RunStackedAction => session.request::(payload), } } @@ -971,7 +978,7 @@ fn uuid_v4(mut bytes: [u8; 16]) -> String { ) } -fn command_id() -> String { +pub fn command_id() -> String { let mut bytes = [0u8; 8]; // A failed read leaves zeros, and the server rejects a repeated command id rather than applying it twice. let _ = getrandom::fill(&mut bytes); diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index ddaecccb42dc..7af81be99209 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -17,6 +17,7 @@ mod providers; mod ref_picker; mod refs; mod rpc; +mod send; mod server; mod shell; mod slow_requests; diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs index ecd925910bc9..0a2bbf2360d8 100644 --- a/apps/desktop-gpui/src/rpc.rs +++ b/apps/desktop-gpui/src/rpc.rs @@ -51,6 +51,24 @@ impl Method for ProjectsMutate { type Success = wire::ProjectsMutateSuccess; } +/// `orchestration.launchThread`, a unary call that creates a thread and starts its first turn. +pub struct OrchestrationLaunchThread; + +impl Method for OrchestrationLaunchThread { + const TAG: &'static str = "orchestration.launchThread"; + type Payload = Value; + type Success = Value; +} + +/// `orchestration.dispatchCommand`, a unary call that carries one thread command. +pub struct OrchestrationDispatchCommand; + +impl Method for OrchestrationDispatchCommand { + const TAG: &'static str = "orchestration.dispatchCommand"; + type Payload = Value; + type Success = Value; +} + /// `server.getConfig`, a unary call. The wire types are not generated; `providers::Catalog` decodes it. pub struct ServerGetConfig; diff --git a/apps/desktop-gpui/src/send.rs b/apps/desktop-gpui/src/send.rs new file mode 100644 index 000000000000..ea1f113ae6cb --- /dev/null +++ b/apps/desktop-gpui/src/send.rs @@ -0,0 +1,551 @@ +//! What the composer sends, mirroring the web's `useComposerSend` and `startThreadTurn` +//! (`packages/client-runtime/src/operations/commands.ts`): a draft becomes a thread through +//! `orchestration.launchThread` with its first message, and a message in an open thread is a +//! `message.dispatch` command. The payloads are untyped JSON like the other unary calls; the +//! tests check them against `rpc.schema.json`. +//! +//! The first caller lands in the next commit. +#![allow(dead_code)] + +use crate::connection::Call; +use crate::strip::{Branch, EnvMode, RuntimeMode}; +use serde_json::{Value, json}; + +/// `truncate`'s default in `packages/shared/src/String.ts`. +const TITLE_MAX_CHARS: usize = 50; +const NEW_THREAD_TITLE: &str = "New thread"; +/// Both the web and the desktop shell it wraps report the web as the creation source. +const CREATION_SOURCE: &str = "web"; + +/// Whether the send button is live: text to send and no send already waiting on the server. +pub fn can_send(text: &str, in_flight: bool) -> bool { + !in_flight && !text.trim().is_empty() +} + +/// What the primary button does, as `ComposerPrimaryActions` picks it: a running turn with +/// nothing typed offers Stop; anything else is Send. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Primary { + Send { enabled: bool }, + Stop, +} + +pub fn primary(running: bool, text: &str, in_flight: bool) -> Primary { + if running && text.trim().is_empty() { + Primary::Stop + } else { + Primary::Send { + enabled: can_send(text, in_flight), + } + } +} + +/// `deriveThreadTitleSeed` for a text-only message: whitespace collapsed, cut at 50 characters. +pub fn title_seed(text: &str) -> String { + let normalized = text.split_whitespace().collect::>().join(" "); + if normalized.is_empty() { + return NEW_THREAD_TITLE.to_owned(); + } + let mut chars = normalized.chars(); + let head: String = chars.by_ref().take(TITLE_MAX_CHARS).collect(); + if chars.next().is_some() { + format!("{head}...") + } else { + head + } +} + +/// A model on a provider instance, the `modelSelection` the contract takes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Model<'a> { + pub instance_id: &'a str, + pub slug: &'a str, +} + +impl Model<'_> { + fn selection(self) -> Value { + json!({"instanceId": self.instance_id, "model": self.slug}) + } +} + +/// Where a draft's thread runs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Workspace<'a> { + /// The project's checkout, on `branch` when it is known. + Root { branch: Option<&'a str> }, + /// A new worktree started from `base`. + Worktree { + base: &'a str, + start_from_origin: bool, + }, +} + +impl<'a> Workspace<'a> { + /// `planComposerTurn`: worktree mode needs an explicit base ref, so a missing one is an + /// error instead of a silent local run. + pub fn resolve( + mode: EnvMode, + shown: Option<&'a Branch>, + start_from_origin: bool, + ) -> Result { + let name = shown.and_then(|branch| branch.name.as_deref()); + match (mode, name) { + (EnvMode::Local, branch) => Ok(Self::Root { branch }), + (EnvMode::Worktree, Some(base)) => Ok(Self::Worktree { + base, + start_from_origin, + }), + (EnvMode::Worktree, None) => { + Err("Select a base branch before sending in New worktree mode.".to_owned()) + } + } + } + + fn strategy(self) -> Value { + match self { + Self::Root { branch: None } => json!({"type": "root"}), + Self::Root { + branch: Some(branch), + } => json!({"type": "root", "branch": branch}), + Self::Worktree { + base, + start_from_origin: true, + } => json!({"type": "worktree", "baseRef": base, "startFromOrigin": true}), + Self::Worktree { base, .. } => json!({"type": "worktree", "baseRef": base}), + } + } +} + +/// How a message reaches a thread, `resolveComposerDispatchMode` with the default +/// `followUpBehavior` of steering a running turn. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Delivery { + Auto, + Steer, +} + +impl Delivery { + pub fn of(running: bool) -> Self { + if running { Self::Steer } else { Self::Auto } + } + + fn intent(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Steer => "steer", + } + } +} + +/// The ids a send mints; the caller supplies them so the plan stays deterministic. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Ids { + pub command: String, + pub thread: String, + pub message: String, +} + +/// The composer as it stands when the user sends. +pub struct Composer<'a> { + /// The open thread; `None` for a draft. + pub thread: Option<&'a str>, + /// The draft's project. + pub project: Option<&'a str>, + pub text: &'a str, + /// The model the trigger shows. + pub model: Option>, + /// Whether the user picked it, so an open thread keeps its own model otherwise. + pub picked: bool, + pub runtime_mode: RuntimeMode, + pub workspace: Result, String>, + pub running: bool, +} + +/// One request to make, and the thread the window opens once it succeeds. +#[derive(Clone, Debug, PartialEq)] +pub struct Plan { + pub call: Call, + pub payload: Value, + pub opens: Option, +} + +/// The first send of a draft launches its thread with the message; every later one dispatches. +pub fn plan(composer: &Composer, ids: Ids) -> Result { + let text = composer.text.trim(); + if text.is_empty() { + return Err("There is nothing to send.".to_owned()); + } + match composer.thread { + Some(thread) => Ok(Plan { + call: Call::DispatchCommand, + payload: dispatch_payload( + &ids.command, + thread, + &ids.message, + text, + composer.model.filter(|_| composer.picked), + Delivery::of(composer.running), + ), + opens: None, + }), + None => { + let project = composer.project.ok_or("There is no project to start in.")?; + let model = composer.model.ok_or("No model is available.")?; + let workspace = composer.workspace.clone()?; + Ok(Plan { + call: Call::LaunchThread, + payload: launch_payload( + &ids, + project, + text, + model, + composer.runtime_mode, + workspace, + ), + opens: Some(ids.thread), + }) + } + } +} + +/// `orchestration.launchThread` as `startThreadTurn` sends it for a draft. +pub fn launch_payload( + ids: &Ids, + project_id: &str, + text: &str, + model: Model, + runtime_mode: RuntimeMode, + workspace: Workspace, +) -> Value { + json!({ + "commandId": ids.command, + "creationSource": CREATION_SOURCE, + "threadId": ids.thread, + "projectId": project_id, + "title": title_seed(text), + "generateTitle": true, + "modelSelection": model.selection(), + "runtimeMode": runtime_mode.wire(), + "interactionMode": "default", + "workspaceStrategy": workspace.strategy(), + "initialMessage": { + "messageId": ids.message, + "text": text, + "attachments": [], + }, + }) +} + +/// `message.dispatch` as `startThreadTurn` sends it to an existing thread. +pub fn dispatch_payload( + command_id: &str, + thread_id: &str, + message_id: &str, + text: &str, + model: Option, + delivery: Delivery, +) -> Value { + let mut command = json!({ + "type": "message.dispatch", + "commandId": command_id, + "createdBy": "user", + "creationSource": CREATION_SOURCE, + "threadId": thread_id, + "messageId": message_id, + "text": text, + "attachments": [], + "titleSeed": title_seed(text), + "deliveryIntent": delivery.intent(), + "dispatchMode": {"type": "start_immediately"}, + }); + if let Some(model) = model { + command["modelSelection"] = model.selection(); + } + command +} + +/// `thread.stop`, which the web sends when the server reports `capabilities.threadStop`. +pub fn stop_payload(command_id: &str, thread_id: &str) -> Value { + json!({"type": "thread.stop", "commandId": command_id, "threadId": thread_id}) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + fn ids() -> Ids { + Ids { + command: "gpui-1".to_owned(), + thread: "thread-1".to_owned(), + message: "message-1".to_owned(), + } + } + + const MODEL: Model = Model { + instance_id: "codex", + slug: "gpt-5", + }; + + fn branch(name: &str) -> Branch { + Branch { + name: Some(name.to_owned()), + is_remote: Some(false), + } + } + + fn composer<'a>(text: &'a str, workspace: Result, String>) -> Composer<'a> { + Composer { + thread: None, + project: Some("project-1"), + text, + model: Some(MODEL), + picked: false, + runtime_mode: RuntimeMode::FullAccess, + workspace, + running: false, + } + } + + /// The schema's definition for `payload`: the method's own, or the union member whose `type` it names. + fn definition(schema: &Value, method: &str, payload: &Value) -> Value { + let target = schema["methods"][method]["payload"]["$ref"] + .as_str() + .and_then(|r| r.strip_prefix("#/$defs/")) + .unwrap_or_else(|| panic!("{method} has no payload ref")); + let def = &schema["$defs"][target]; + let Some(members) = def["anyOf"].as_array() else { + return def.clone(); + }; + members + .iter() + .find(|member| member["properties"]["type"]["enum"][0] == payload["type"]) + .unwrap_or_else(|| panic!("{method} has no member for {}", payload["type"])) + .clone() + } + + /// Every required property is present and nothing is named that the contract lacks. + fn assert_matches_contract(method: &str, payload: &Value) { + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../packages/contracts/schema/rpc.schema.json" + ); + let schema: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + let def = definition(&schema, method, payload); + let keys = |value: &Value| -> BTreeSet { + value.as_object().unwrap().keys().cloned().collect() + }; + let sent = keys(payload); + let known = keys(&def["properties"]); + let required: BTreeSet = def["required"] + .as_array() + .unwrap() + .iter() + .map(|name| name.as_str().unwrap().to_owned()) + .collect(); + assert!( + required.is_subset(&sent), + "missing {:?}", + required.difference(&sent) + ); + assert!( + sent.is_subset(&known), + "unknown {:?}", + sent.difference(&known) + ); + } + + #[test] + fn a_draft_launches_its_thread_with_the_first_message() { + let root = Workspace::Root { + branch: Some("main"), + }; + let plan = plan(&composer(" fix it\n now ", Ok(root)), ids()).unwrap(); + assert_eq!(plan.call, Call::LaunchThread); + assert_eq!(plan.opens.as_deref(), Some("thread-1")); + assert_eq!( + plan.payload, + json!({ + "commandId": "gpui-1", + "creationSource": "web", + "threadId": "thread-1", + "projectId": "project-1", + "title": "fix it now", + "generateTitle": true, + "modelSelection": {"instanceId": "codex", "model": "gpt-5"}, + "runtimeMode": "full-access", + "interactionMode": "default", + "workspaceStrategy": {"type": "root", "branch": "main"}, + "initialMessage": { + "messageId": "message-1", + "text": "fix it\n now", + "attachments": [], + }, + }) + ); + assert_matches_contract("orchestration.launchThread", &plan.payload); + } + + #[test] + fn a_worktree_draft_names_its_base_ref() { + let main = branch("main"); + let origin = Workspace::resolve(EnvMode::Worktree, Some(&main), true).unwrap(); + let local = Workspace::resolve(EnvMode::Worktree, Some(&main), false).unwrap(); + let strategy = |workspace| { + plan(&composer("go", Ok(workspace)), ids()).unwrap().payload["workspaceStrategy"] + .clone() + }; + assert_eq!( + strategy(origin), + json!({"type": "worktree", "baseRef": "main", "startFromOrigin": true}) + ); + assert_eq!( + strategy(local), + json!({"type": "worktree", "baseRef": "main"}) + ); + assert_matches_contract( + "orchestration.launchThread", + &plan(&composer("go", Ok(origin)), ids()).unwrap().payload, + ); + } + + #[test] + fn worktree_mode_without_a_base_ref_does_not_send() { + let nameless = Branch { + name: None, + is_remote: None, + }; + for shown in [None, Some(&nameless)] { + let workspace = Workspace::resolve(EnvMode::Worktree, shown, true); + assert!(workspace.is_err()); + assert!(plan(&composer("go", workspace), ids()).is_err()); + } + assert_eq!( + Workspace::resolve(EnvMode::Local, None, true), + Ok(Workspace::Root { branch: None }) + ); + } + + #[test] + fn a_message_in_an_open_thread_dispatches() { + let mut open = composer("again", Ok(Workspace::Root { branch: None })); + open.thread = Some("thread-9"); + let plan = plan(&open, ids()).unwrap(); + assert_eq!(plan.call, Call::DispatchCommand); + assert_eq!(plan.opens, None); + assert_eq!( + plan.payload, + json!({ + "type": "message.dispatch", + "commandId": "gpui-1", + "createdBy": "user", + "creationSource": "web", + "threadId": "thread-9", + "messageId": "message-1", + "text": "again", + "attachments": [], + "titleSeed": "again", + "deliveryIntent": "auto", + "dispatchMode": {"type": "start_immediately"}, + }) + ); + assert_matches_contract("orchestration.dispatchCommand", &plan.payload); + } + + #[test] + fn an_open_thread_changes_its_model_only_when_one_was_picked() { + let mut open = composer("again", Ok(Workspace::Root { branch: None })); + open.thread = Some("thread-9"); + let selection = + |open: &Composer| plan(open, ids()).unwrap().payload["modelSelection"].clone(); + assert_eq!(selection(&open), Value::Null); + open.picked = true; + assert_eq!( + selection(&open), + json!({"instanceId": "codex", "model": "gpt-5"}) + ); + assert_matches_contract( + "orchestration.dispatchCommand", + &plan(&open, ids()).unwrap().payload, + ); + } + + #[test] + fn a_running_turn_is_steered() { + let mut open = composer("wait", Ok(Workspace::Root { branch: None })); + open.thread = Some("thread-9"); + open.running = true; + let payload = plan(&open, ids()).unwrap().payload; + assert_eq!(payload["deliveryIntent"], "steer"); + assert_eq!( + payload["dispatchMode"], + json!({"type": "start_immediately"}) + ); + } + + #[test] + fn the_runtime_mode_goes_out_as_the_contract_spells_it() { + for mode in RuntimeMode::ALL { + let mut draft = composer("go", Ok(Workspace::Root { branch: None })); + draft.runtime_mode = mode; + let payload = plan(&draft, ids()).unwrap().payload; + assert_eq!( + RuntimeMode::parse(payload["runtimeMode"].as_str().unwrap()), + Some(mode) + ); + } + } + + #[test] + fn an_empty_message_or_a_draft_without_a_model_is_not_planned() { + let root = || Ok(Workspace::Root { branch: None }); + assert!(plan(&composer(" \n ", root()), ids()).is_err()); + let mut no_model = composer("go", root()); + no_model.model = None; + assert!(plan(&no_model, ids()).is_err()); + let mut no_project = composer("go", root()); + no_project.project = None; + assert!(plan(&no_project, ids()).is_err()); + } + + #[test] + fn stop_is_one_command() { + let payload = stop_payload("gpui-2", "thread-9"); + assert_eq!( + payload, + json!({"type": "thread.stop", "commandId": "gpui-2", "threadId": "thread-9"}) + ); + assert_matches_contract("orchestration.dispatchCommand", &payload); + } + + #[test] + fn send_is_enabled_by_text_and_blocked_while_in_flight() { + assert!(can_send("hi", false)); + assert!(!can_send(" \n", false)); + assert!(!can_send("hi", true)); + } + + #[test] + fn a_running_turn_with_an_empty_composer_offers_stop() { + assert_eq!(primary(true, "", false), Primary::Stop); + assert_eq!(primary(true, " ", true), Primary::Stop); + assert_eq!( + primary(true, "more", false), + Primary::Send { enabled: true } + ); + assert_eq!(primary(false, "", false), Primary::Send { enabled: false }); + assert_eq!(primary(false, "hi", true), Primary::Send { enabled: false }); + } + + #[test] + fn titles_collapse_whitespace_and_cut_at_fifty_characters() { + assert_eq!(title_seed(" a \n\t b "), "a b"); + assert_eq!(title_seed(""), "New thread"); + let fifty = "x".repeat(50); + assert_eq!(title_seed(&fifty), fifty); + assert_eq!(title_seed(&format!("{fifty}y")), format!("{fifty}...")); + // Characters, not bytes. + let wide = "\u{e9}".repeat(51); + assert_eq!(title_seed(&wide), format!("{}...", "\u{e9}".repeat(50))); + } +} diff --git a/apps/desktop-gpui/src/strip.rs b/apps/desktop-gpui/src/strip.rs index b916c8ff9ffc..8fdfd20e1969 100644 --- a/apps/desktop-gpui/src/strip.rs +++ b/apps/desktop-gpui/src/strip.rs @@ -52,7 +52,7 @@ pub enum RuntimeMode { } impl RuntimeMode { - fn parse(text: &str) -> Option { + pub(crate) fn parse(text: &str) -> Option { match text { "approval-required" => Some(Self::ApprovalRequired), "auto-accept-edits" => Some(Self::AutoAcceptEdits), @@ -62,6 +62,16 @@ impl RuntimeMode { } } + /// The contract's spelling, the inverse of `parse`. + pub fn wire(self) -> &'static str { + match self { + Self::ApprovalRequired => "approval-required", + Self::AutoAcceptEdits => "auto-accept-edits", + Self::Auto => "auto", + Self::FullAccess => "full-access", + } + } + /// `runtimeModeOptions`, the order of the permissions toggle. pub const ALL: [Self; 4] = [ Self::ApprovalRequired, From 1f4f5321c84e409f9e3159f0d081c218a8939fb0 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 22:09:14 -0500 Subject: [PATCH 47/82] feat(desktop-gpui): send from the composer and stop a running turn --- apps/desktop-gpui/src/send.rs | 3 - apps/desktop-gpui/src/ui.rs | 220 +++++++++++++++++++++++++++++++--- 2 files changed, 202 insertions(+), 21 deletions(-) diff --git a/apps/desktop-gpui/src/send.rs b/apps/desktop-gpui/src/send.rs index ea1f113ae6cb..7ce7f8f513e6 100644 --- a/apps/desktop-gpui/src/send.rs +++ b/apps/desktop-gpui/src/send.rs @@ -3,9 +3,6 @@ //! `orchestration.launchThread` with its first message, and a message in an open thread is a //! `message.dispatch` command. The payloads are untyped JSON like the other unary calls; the //! tests check them against `rpc.schema.json`. -//! -//! The first caller lands in the next commit. -#![allow(dead_code)] use crate::connection::Call; use crate::strip::{Branch, EnvMode, RuntimeMode}; diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index e515f57fe066..527b8fa0f4f9 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -1,15 +1,16 @@ use crate::assets::{MESSAGE_SQUARE_DASHED, STAGE_DEV, STAGE_DEV_BUTTON, provider_icon}; use crate::branch_picker::{self, BranchPicker}; -use crate::connection::{Action, ActionSender, new_project_id}; +use crate::connection::{Action, ActionSender, command_id, new_project_id}; use crate::details::{self, VcsStatus}; use crate::details_card::{DetailsCard, OpenDiff, Props}; use crate::history::History; use crate::minimap; use crate::projects::{self, EnvironmentProjects, MachineKind, NewProject, ProjectEntry}; use crate::providers::{self, Catalog, Mark, Pick, Trigger}; +use crate::send::{self, Primary}; use crate::slow_requests; use crate::status::{ - LastError, PrBadge, PrState, Section, ShellView, Status, ThreadRow, TopStatus, + CallEvent, LastError, PrBadge, PrState, Section, ShellView, Status, ThreadRow, TopStatus, }; use crate::strip::{ self, Branch, Defaults, EnvMode, HOST_SHORTCUT, PERMISSIONS_SHORTCUT, RuntimeMode, @@ -266,9 +267,25 @@ pub struct MainView { slow_toast: Option, /// The main area's width at the last render, for fitting the details card. main_width: Cell, + /// The send on its way to the server, which keeps the composer's text until it answers. + sending: Option, + /// Set when a send succeeded; the next render empties the composer, which needs a window. + clear_composer: bool, actions: ActionSender, } +/// What a successful send changes in the window. +struct Sending { + /// The thread a draft became. + opens: Option, + /// The draft's details key, whose model pick the new thread inherits. + from_key: Option, +} + +/// Send and stop calls, above the branch picker's range. +const SEND_TOKEN: u64 = 1 << 41; +const STOP_TOKEN: u64 = SEND_TOKEN + 1; + impl MainView { pub fn new( status: Status, @@ -297,12 +314,14 @@ impl MainView { .placeholder("Ask anything. @ files, $ skills, / commands") // The web's `min-h-[2lh]` and `max-h-52`. .auto_grow(2, 9) + // Enter sends; Shift+Enter inserts a newline. + .submit_on_enter(true) }); // The send button enables with content. - cx.subscribe(&composer, |_, _, event: &InputEvent, cx| { - if matches!(event, InputEvent::Change) { - cx.notify(); - } + cx.subscribe(&composer, |this, _, event: &InputEvent, cx| match event { + InputEvent::Change => cx.notify(), + InputEvent::PressEnter { shift: false, .. } => this.submit(cx), + _ => {} }) .detach(); let view = cx.weak_entity(); @@ -389,6 +408,8 @@ impl MainView { toasts: cx.new(|cx| ToastHost::new(TOPBAR_HEIGHT, window, cx)), slow_toast: None, main_width: Cell::new(0.), + sending: None, + clear_composer: false, actions, } } @@ -479,6 +500,10 @@ impl MainView { return cx.notify(); } if let Status::Call { token, event } = status { + if token >= SEND_TOKEN { + self.on_send_call(token, event, cx); + return; + } if token >= branch_picker::TOKEN_BASE { self.branch_picker .update(cx, |picker, cx| picker.on_call(token, event, cx)); @@ -515,6 +540,143 @@ impl MainView { cx.notify(); } + /// Sends the composer: a draft launches its thread, an open thread takes a message. + fn submit(&mut self, cx: &mut Context) { + let text = self.composer.read(cx).value().to_string(); + if !send::can_send(&text, self.sending.is_some()) { + return; + } + let planned = { + let project = self + .shell() + .and_then(|shell| active_project(self.selected_project.as_deref(), shell)); + let project_id = project.map(|entry| entry.id.as_str()); + let defaults = self.providers.as_ref().map_or_else( + || Defaults::resolve(None, None, None), + |catalog| catalog.defaults(project_id), + ); + let key = self.details_key(); + let pick = self.model_pick(key.as_deref()); + let trigger = self + .providers + .as_ref() + .and_then(|catalog| catalog.trigger_for(project_id, pick)); + let mode = project + .and_then(|entry| self.env_modes.get(&entry.id).copied()) + .unwrap_or(defaults.env_mode); + let current = project + .and_then(|entry| self.branches.get(&entry.root)) + .and_then(Option::as_ref); + let base = project.and_then(|entry| self.draft_bases.get(&entry.id)); + let composer = send::Composer { + thread: self.selected_thread.as_deref(), + project: project_id, + text: &text, + model: trigger.as_ref().map(|trigger| send::Model { + instance_id: &trigger.instance_id, + slug: &trigger.slug, + }), + picked: pick.is_some(), + runtime_mode: self.runtime_mode.unwrap_or(defaults.runtime_mode), + workspace: send::Workspace::resolve( + mode, + strip::shown_branch(mode, current, base), + defaults.start_from_origin, + ), + running: self.thread_running(), + }; + send::plan( + &composer, + send::Ids { + command: command_id(), + thread: new_project_id(), + message: new_project_id(), + }, + ) + .map(|plan| (plan, key)) + }; + let (plan, from_key) = match planned { + Ok(planned) => planned, + Err(message) => return self.error_toast("Could not send the message", message, cx), + }; + let action = Action::Call { + token: SEND_TOKEN, + call: plan.call, + payload: plan.payload, + }; + if self.actions.try_send(action).is_err() { + return self.error_toast( + "Could not send the message", + "The connection is not ready.".to_owned(), + cx, + ); + } + self.sending = Some(Sending { + opens: plan.opens, + from_key, + }); + cx.notify(); + } + + /// The web's stop button: `thread.stop` for the open thread. + fn stop(&mut self, cx: &mut Context) { + let Some(thread) = self.selected_thread.clone() else { + return; + }; + let action = Action::Call { + token: STOP_TOKEN, + call: crate::connection::Call::DispatchCommand, + payload: send::stop_payload(&command_id(), &thread), + }; + if self.actions.try_send(action).is_err() { + self.error_toast( + "Failed to stop the turn", + "The connection is not ready.".to_owned(), + cx, + ); + } + } + + fn on_send_call(&mut self, token: u64, event: CallEvent, cx: &mut Context) { + let failed = match event { + CallEvent::Progress(_) => return, + CallEvent::Done(_) => None, + CallEvent::Failed(error) => Some(error), + }; + if token == STOP_TOKEN { + if let Some(error) = failed { + self.error_toast("Failed to stop the turn", error, cx); + } + return; + } + let Some(sent) = self.sending.take() else { + return; + }; + if let Some(error) = failed { + self.error_toast("Could not send the message", error, cx); + return cx.notify(); + } + self.clear_composer = true; + if let Some(thread) = sent.opens { + if let Some(pick) = sent.from_key.and_then(|key| self.model_picks.remove(&key)) { + self.model_picks.insert(thread.clone(), pick); + } + self.open_thread(Some(thread), cx); + } + cx.notify(); + } + + /// Whether the open thread is mid-turn, which turns Stop on and makes a message steer. + fn thread_running(&self) -> bool { + let id = self.selected_thread.as_deref(); + self.shell().is_some_and(|shell| { + shell + .rows + .iter() + .any(|row| Some(row.id.as_str()) == id && row.top == Some(TopStatus::Working)) + }) + } + fn error_toast(&mut self, title: &str, description: String, cx: &mut Context) { let toast = Toast::new(title.to_owned()) .kind(ToastKind::Error) @@ -3329,6 +3491,11 @@ impl MainView { fn composer_box(&self, cx: &mut Context) -> impl IntoElement { let model_picker = self.model_popover(cx); + let primary = send::primary( + self.thread_running(), + &self.composer.read(cx).value(), + self.sending.is_some(), + ); v_flex() .w(px(720.)) .max_w_full() @@ -3363,17 +3530,15 @@ impl MainView { )) .child( h_flex().gap_2().children(model_picker).child( - send_button( - !self.composer.read(cx).value().trim().is_empty(), - self.send_hovered, - cx, - ) - .on_hover(cx.listener( - |this, hovered: &bool, _, cx| { + send_button(primary, self.send_hovered, cx) + .on_hover(cx.listener(|this, hovered: &bool, _, cx| { this.send_hovered = *hovered; cx.notify(); - }, - )), + })) + .on_click(cx.listener(move |this, _, _, cx| match primary { + Primary::Stop => this.stop(cx), + Primary::Send { .. } => this.submit(cx), + })), ), ), ) @@ -4030,7 +4195,9 @@ fn footer_button( } /// The web's `ComposerPrimaryActions` send button: stage art in dev builds, else `message-action`. -fn send_button(enabled: bool, hovered: bool, cx: &App) -> gpui::Stateful { +fn send_button(primary: Primary, hovered: bool, cx: &App) -> gpui::Stateful { + let enabled = !matches!(primary, Primary::Send { enabled: false }); + let stop = primary == Primary::Stop; let theme = cx.theme(); let (bg, hover_bg, fg) = if STAGE_BACKDROP { ( @@ -4056,7 +4223,11 @@ fn send_button(enabled: bool, hovered: bool, cx: &App) -> gpui::Stateful gpui::Stateful) -> impl IntoElement { + if std::mem::take(&mut self.clear_composer) { + self.composer + .update(cx, |composer, cx| composer.set_value("", window, cx)); + } let width = f32::from(window.viewport_size().width); let narrow = is_narrow(width); let docked = self.sidebar_open && !narrow; From 92a88c101e3cdf8bba422e565f48a39a84b1b6f5 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 22:28:50 -0500 Subject: [PATCH 48/82] fix(desktop-gpui): match the web details card on drafts and split rows A fresh draft showed a dead Commit row and a Changes row labelled with its base ref, and split rows filled as one block on hover. Git actions now hide on a quiet draft and in compact density unless an action is running, the Changes row shows only its totals, each half of a split row stacks its own hover fill with the open menu keeping it, and the editor menu matches the row width and picks the favorite instead of opening it. --- apps/desktop-gpui/src/details.rs | 20 +++++---- apps/desktop-gpui/src/details_card.rs | 59 ++++++++++++++------------- 2 files changed, 44 insertions(+), 35 deletions(-) diff --git a/apps/desktop-gpui/src/details.rs b/apps/desktop-gpui/src/details.rs index 88e7d1f50320..223cb1ddbea6 100644 --- a/apps/desktop-gpui/src/details.rs +++ b/apps/desktop-gpui/src/details.rs @@ -11,6 +11,9 @@ pub const OPEN_FAVORITE_SHORTCUT: &str = "\u{2318}O"; /// `THREAD_DETAILS_CARD_WIDTH` and `THREAD_DETAILS_CARD_GAP`. pub const CARD_WIDTH: f32 = 280.; +/// A row inside the card: its width less the 1px border and `px-2` padding on each side, which +/// the web's panel menus match with `w-(--anchor-width)`. +pub const CARD_ROW_WIDTH: f32 = CARD_WIDTH - 2. - 16.; pub const CARD_GAP: f32 = 12.; /// `DETAILS_CARD_CLEARANCE`, and the chat lane's padding and minimum width (`canvasStore.ts`). const CARD_CLEARANCE: f32 = 32.; @@ -62,6 +65,8 @@ pub struct Inputs { pub has_editors: bool, /// A fresh draft or a thread with no run, with nothing to commit or push. pub quiet_empty_draft: bool, + /// A git action in flight, or the notice its result left. + pub git_activity: bool, /// The rows the usage limits would take: one per window, or one for the notice; 0 for none. pub usage_rows: usize, } @@ -82,7 +87,10 @@ impl Show { Self { open_in: inputs.has_cwd && inputs.has_editors && density != Density::Essential, branch: inputs.has_cwd && inputs.is_repo, - git_actions: inputs.has_cwd && inputs.is_repo, + git_actions: inputs.has_cwd + && inputs.is_repo + && !inputs.quiet_empty_draft + && (density == Density::Full || inputs.git_activity), changes: inputs.has_cwd && inputs.is_repo && !inputs.quiet_empty_draft, usage: inputs.usage_rows > 0 && density != Density::Essential, } @@ -224,8 +232,6 @@ pub struct Local { pub has_working_tree_changes: bool, pub working_tree: Lines, pub branch_changes: Option, - /// The base the branch totals compare against (`branchChanges.baseRef`). - pub base_ref: Option, /// `workingTree.files`, which the commit dialog lists. pub files: Vec, /// `sourceControlProvider.kind`, which picks PR or MR wording. @@ -288,9 +294,6 @@ impl Local { }) }) .collect(), - base_ref: value["branchChanges"]["baseRef"] - .as_str() - .map(str::to_owned), provider_kind: value["sourceControlProvider"]["kind"] .as_str() .map(str::to_owned), @@ -416,6 +419,7 @@ mod tests { is_repo: true, has_editors: true, quiet_empty_draft: false, + git_activity: false, usage_rows: 0, } } @@ -488,7 +492,9 @@ mod tests { let not_repo = show(Density::Full, |i| i.is_repo = false); assert!(not_repo.open_in && !not_repo.branch && !not_repo.git_actions && !not_repo.changes); let quiet = show(Density::Full, |i| i.quiet_empty_draft = true); - assert!(quiet.branch && quiet.git_actions && !quiet.changes); + assert!(quiet.branch && !quiet.git_actions && !quiet.changes); + assert!(!show(Density::Compact, |_| {}).git_actions); + assert!(show(Density::Compact, |i| i.git_activity = true).git_actions); assert!(show(Density::Full, |i| i.has_cwd = false).is_empty()); } diff --git a/apps/desktop-gpui/src/details_card.rs b/apps/desktop-gpui/src/details_card.rs index 202c065d4cc3..118ad94931a1 100644 --- a/apps/desktop-gpui/src/details_card.rs +++ b/apps/desktop-gpui/src/details_card.rs @@ -660,7 +660,11 @@ impl RenderOnce for Trigger { /// The chevron segment of a split button. fn chevron(id: &'static str, label: &'static str, open: bool, cx: &App) -> AnyElement { let theme = cx.theme(); - let (muted, foreground) = (theme.muted_foreground, theme.foreground); + let (muted, foreground, fill) = ( + theme.muted_foreground, + theme.foreground, + hover_fill(theme.is_dark()), + ); div() .id(id) .flex() @@ -669,18 +673,23 @@ fn chevron(id: &'static str, label: &'static str, open: bool, cx: &App) -> AnyEl .w_8() .h_full() .flex_shrink_0() + .rounded_r(px(8.)) .cursor_pointer() + .hover(move |style| style.bg(fill)) + .when(open, |chevron| chevron.bg(fill)) .text_color(if open { foreground } else { muted }) .tooltip(tip(label)) .child(Icon::new(Icons::ChevronDown).size_4()) .into_any_element() } -/// A `h-8` panel row: the primary segment, a separator on hover, and the chevron. +/// A `h-8` panel row: the primary segment, a separator on hover, and the chevron. Like the web, +/// the row fills on hover and the hovered half stacks a second fill; an open menu keeps both. fn split_row( group: &'static str, primary: impl IntoElement, chevron: impl IntoElement, + open: bool, cx: &App, ) -> gpui::Div { let theme = cx.theme(); @@ -690,15 +699,18 @@ fn split_row( .h_8() .rounded(px(8.)) .hover(move |style| style.bg(fill)) + .when(open, |row| row.bg(fill)) .child(primary) .child( div() .w(px(1.)) .h_4() .flex_shrink_0() - .bg(separator) - .opacity(0.) - .group_hover(group, |style| style.opacity(1.)), + .bg(separator.opacity(0.65)) + .when(!open, |line| { + line.opacity(0.) + .group_hover(group, |style| style.opacity(1.)) + }), ) .child(chevron) } @@ -710,7 +722,8 @@ fn primary_segment( label: String, cx: &App, ) -> gpui::Stateful { - let foreground = cx.theme().foreground; + let theme = cx.theme(); + let (foreground, fill) = (theme.foreground, hover_fill(theme.is_dark())); h_flex() .id(id) .flex_1() @@ -720,6 +733,7 @@ fn primary_segment( .gap(px(10.)) .rounded_l(px(8.)) .cursor_pointer() + .hover(move |style| style.bg(fill)) .text_size(px(13.)) .font_weight(FontWeight::MEDIUM) .text_color(foreground.opacity(0.8)) @@ -821,7 +835,7 @@ impl DetailsCard { let (foreground, muted, hover) = (theme.foreground, theme.muted_foreground, theme.accent); v_flex() - .w(px(240.)) + .w(px(details::CARD_ROW_WIDTH)) .p_1() .children(editors.iter().map(|&id| { let view = view.clone(); @@ -841,6 +855,7 @@ impl DetailsCard { row.child( div() .text_xs() + .font_weight(FontWeight::MEDIUM) .text_color(muted) .child(details::OPEN_FAVORITE_SHORTCUT), ) @@ -848,12 +863,13 @@ impl DetailsCard { .on_click(move |_, _, cx| { view.update(cx, |this, cx| { this.editor_open = false; - this.open_in_editor(id, cx); + this.pick = Some(id.to_owned()); + cx.notify(); }); }) })) }); - split_row("open-in-split", primary, popover, cx) + split_row("open-in-split", primary, popover, open, cx) } fn branch_row(&self, status: &VcsStatus, cx: &mut Context) -> AnyElement { @@ -1014,7 +1030,7 @@ impl DetailsCard { let icon = row_icon(quick_icon(quick.kind, &quick.label), cx); let mut primary = primary_segment("git-quick-action", icon, quick.label.clone(), cx); if disabled { - primary = primary.opacity(0.5).cursor_default(); + primary = primary.opacity(0.5).cursor_default().hover(|style| style); if let Some(hint) = hint { primary = primary.tooltip(tip(hint)); } @@ -1089,7 +1105,7 @@ impl DetailsCard { .child(text) })) }); - split_row("git-split", primary, popover, cx).into_any_element() + split_row("git-split", primary, popover, self.git_open, cx).into_any_element() } fn changes_row(&self, status: &VcsStatus, cx: &mut Context) -> impl IntoElement { @@ -1109,22 +1125,7 @@ impl DetailsCard { panel_row("changes-row", cx) .when(opens_diff, |row| row.cursor_pointer().on_click(open_diff)) .child(row_icon(Icons::FileDiff, cx)) - .child(div().flex_shrink_0().child("Changes")) - .child( - div() - .flex_1() - .min_w_0() - .truncate() - .text_xs() - .text_color(muted) - .children( - status - .local - .base_ref - .as_ref() - .map(|base| format!("vs {base}")), - ), - ) + .child(div().flex_1().min_w_0().truncate().child("Changes")) .child( h_flex() .flex_shrink_0() @@ -1238,7 +1239,9 @@ impl Render for DetailsCard { has_cwd: true, is_repo: status.is_some_and(|status| status.local.is_repo), has_editors: !props.editors.is_empty(), - quiet_empty_draft: details::quiet_empty_draft(props.has_run, status), + quiet_empty_draft: self.run.is_none() + && details::quiet_empty_draft(props.has_run, status), + git_activity: self.run.is_some() || matches!(self.notice, Some(Notice::Success { .. })), usage_rows, }; let density = details::density_for(props.max_height, inputs); From 4a352a335115aa3433a22100dcd713369557eba4 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 22:38:27 -0500 Subject: [PATCH 49/82] feat(desktop-gpui): sidebar PR badge opens a hover popup of linked pull requests --- apps/desktop-gpui/src/shell.rs | 15 +- apps/desktop-gpui/src/status.rs | 4 + apps/desktop-gpui/src/thread_card.rs | 3 + apps/desktop-gpui/src/ui.rs | 259 +++++++++++++++++++++++---- 4 files changed, 241 insertions(+), 40 deletions(-) diff --git a/apps/desktop-gpui/src/shell.rs b/apps/desktop-gpui/src/shell.rs index 4db9ec3edfda..3f15fe188cc2 100644 --- a/apps/desktop-gpui/src/shell.rs +++ b/apps/desktop-gpui/src/shell.rs @@ -304,6 +304,8 @@ fn pr_badge(thread: &Thread) -> Option { state: PrState::Pending, stack: false, text: linked.number.to_string(), + url: Some(linked.url.clone()), + opens_list: false, }); } let state_of = |link: &&PullRequestsItem| match link.snapshot.as_ref() { @@ -323,6 +325,8 @@ fn pr_badge(thread: &Thread) -> Option { .map_or(PrState::Pending, |_| state_of(only)), stack: false, text: only.number.to_string(), + url: Some(only.url.clone()), + opens_list: false, }); } let states: Vec = visible.iter().map(state_of).collect(); @@ -343,6 +347,8 @@ fn pr_badge(thread: &Thread) -> Option { Some(PrBadge { state, stack, + url: None, + opens_list: true, text: if stack { visible.len().to_string() } else { @@ -769,16 +775,17 @@ mod tests { }; let badge = |links: Vec| { pr_badge(&thread("t", |t| set(t, &[("pullRequests", links.into())]))) - .map(|badge| (badge.state, badge.text)) + .map(|badge| (badge.state, badge.text, badge.url, badge.opens_list)) }; + let url = |number: u64| Some(format!("https://github.com/o/r/pull/{number}")); assert_eq!(badge(vec![]), None); assert_eq!( badge(vec![link(7, "manual", None)]), - Some((PrState::Pending, "7".to_owned())) + Some((PrState::Pending, "7".to_owned(), url(7), false)) ); assert_eq!( badge(vec![link(7, "manual", Some("merged"))]), - Some((PrState::Merged, "7".to_owned())) + Some((PrState::Merged, "7".to_owned(), url(7), false)) ); assert_eq!( badge(vec![ @@ -786,7 +793,7 @@ mod tests { link(2, "agent", Some("open")), link(3, "stack-dismissed", Some("closed")), ]), - Some((PrState::Open, "+2".to_owned())) + Some((PrState::Open, "+2".to_owned(), None, true)) ); } diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index ac861b40ea3c..aa0706172dc5 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -149,6 +149,10 @@ pub struct PrBadge { pub state: PrState, pub stack: bool, pub text: String, + /// The pull request a click opens, for a badge that is a link. + pub url: Option, + /// `opensList`: a stack or several pull requests, so a click shows the list, not a link. + pub opens_list: bool, } /// The status the web's thread card shows at the top right (`topStatus` in `SidebarThreadRow`). diff --git a/apps/desktop-gpui/src/thread_card.rs b/apps/desktop-gpui/src/thread_card.rs index 3b782cfc46ca..ebe09f1da9ea 100644 --- a/apps/desktop-gpui/src/thread_card.rs +++ b/apps/desktop-gpui/src/thread_card.rs @@ -25,6 +25,7 @@ pub struct PrLine { /// The snapshot's title, else the repository. pub title: String, pub depth: usize, + pub url: String, /// On a chain's first line: its kind and how many pull requests it holds. pub stack: Option<(ChainKind, usize)>, } @@ -218,6 +219,7 @@ pub fn pr_lines(links: &[PullRequestsItem]) -> Vec { .as_ref() .map_or_else(|| link.repository.clone(), |s| s.title.clone()), depth: chain.depths[position], + url: link.url.clone(), stack: (position == 0 && chain.layers.len() > 1) .then_some((chain.kind, chain.layers.len())), } @@ -317,6 +319,7 @@ mod tests { let result = lines(vec![bare]); assert_eq!(result[0].state, PrState::Pending); assert_eq!(result[0].title, "o/r"); + assert_eq!(result[0].url, "https://github.com/o/r/pull/7"); } #[test] diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 527b8fa0f4f9..0ff1f5cb3d8f 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -1661,13 +1661,27 @@ impl MainView { _ => None, }, ); + let theme = cx.theme(); + let (muted, dark) = (theme.muted_foreground, theme.is_dark()); + let pr_control = row.pr.as_ref().map(|badge| { + let weak = cx.weak_entity(); + let list_id = row.id.clone(); + pr_badge_control(row, badge, muted, dark, move |cx| { + let list_id = list_id.clone(); + weak.update(cx, |this, cx| this.open_thread(Some(list_id), cx)) + .ok(); + }) + }); thread_row( row, project, provider, selected, self.rendered_sidebar_width.get(), - anchor.clone(), + RowParts { + anchor: anchor.clone(), + pr_control, + }, cx, ) .tooltip(move |window, cx| { @@ -4669,6 +4683,196 @@ fn card_action( .tooltip(tooltip(label)) } +/// One `ThreadPullRequestMiniListItem`: state glyph, `#number`, title and the chain tag. +fn pr_line_row( + line: &PrLine, + mono: gpui::SharedString, + muted: Hsla, + foreground: Hsla, + dark: bool, +) -> gpui::Div { + let tone = pr_color(line.state, dark).unwrap_or(muted); + h_flex() + .min_w_0() + .items_center() + .gap_2() + .pl(px(line.depth.min(3) as f32 * 12.)) + .child( + Icon::new(pr_state_icon(line.state)) + .size(px(12.)) + .flex_shrink_0() + .text_color(tone), + ) + .child( + div() + .flex_shrink_0() + .font_family(mono) + .child(format!("#{}", line.number)), + ) + .child( + div() + .min_w_0() + .truncate() + .text_color(foreground.opacity(0.75)) + .child(line.title.clone()), + ) + .children(line.stack.map(|(kind, size)| { + let word = match kind { + ChainKind::Native => "stack", + ChainKind::Derived => "chain", + }; + div() + .ml_auto() + .flex_shrink_0() + .pl_1() + .text_size(px(10.)) + .child(format!("{word} \u{b7} {size}")) + })) +} + +/// What the badge popup lists: the thread's links, else the one pull request the badge names. +fn badge_lines(badge: &PrBadge, lines: &[PrLine]) -> Vec { + if !lines.is_empty() { + return lines.to_vec(); + } + match (&badge.url, badge.text.parse::()) { + (Some(url), Ok(number)) => vec![PrLine { + number, + state: badge.state, + title: format!("Pull request #{number}"), + depth: 0, + url: url.clone(), + stack: None, + }], + _ => Vec::new(), + } +} + +/// The web's `w-80` and the height of one `px-1 py-1` item at `text-xs`. +const POPUP_WIDTH: f32 = 320.; +const POPUP_ITEM_HEIGHT: f32 = 24.; + +/// The popup's height: the list's rows, capped at the room above the badge. +fn popup_height(lines: usize, room: f32) -> f32 { + let rows = lines as f32 * POPUP_ITEM_HEIGHT + lines.saturating_sub(1) as f32 * 4.; + // The popup's own `p-1`, the border and the list's `-mx-1`. + (rows + 8. + 2.).min((room - 10.).max(POPUP_ITEM_HEIGHT)) +} + +/// GPUI places a tooltip at the pointer, so the popup moves itself to sit centred on top of the +/// badge with no gap, which keeps the pointer inside the trigger or the popup while it travels +/// (`side="top" sideOffset={0}`). +fn popup_offset( + anchor: Bounds, + mouse: gpui::Point, + height: f32, +) -> gpui::Point { + let left = (anchor.center().x - px(POPUP_WIDTH / 2.)).max(px(8.)); + gpui::point( + left - (mouse.x + px(1.)), + anchor.top() - px(height) - (mouse.y + px(1.)), + ) +} + +/// The web's `PullRequestBadge` tooltip popup: a mini list whose items open their pull request. +struct PrPopup { + lines: Vec, + place: gpui::Point, + height: f32, +} + +impl Render for PrPopup { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = cx.theme(); + let (muted, foreground, accent) = (theme.muted_foreground, theme.foreground, theme.accent); + let (dark, mono) = (theme.is_dark(), theme.mono_font_family.clone()); + let items = self.lines.iter().enumerate().map(|(index, line)| { + let url = line.url.clone(); + pr_line_row(line, mono.clone(), muted, foreground, dark) + .id(("pr-popup-item", index)) + .px_1() + .py_1() + .rounded_sm() + .cursor_pointer() + .hover(move |style| style.bg(accent)) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(move |_, _, cx| { + cx.stop_propagation(); + cx.open_url(&url); + }) + }); + div().relative().left(self.place.x).top(self.place.y).child( + div() + .id("pr-popup") + .w(px(POPUP_WIDTH)) + .max_h(px(self.height)) + .p_1() + .rounded_lg() + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .shadow_lg() + .overflow_y_scroll() + .text_xs() + .text_color(muted) + .child(v_flex().gap_1().children(items)), + ) + } +} + +/// The badge in a row's meta line: a link for one pull request, else it opens the list. Hovering +/// shows the popup. The web opens the right panel's pull requests view for a list; GPUI has +/// none, so it activates the thread and the popup stays the list. +fn pr_badge_control( + row: &ThreadRow, + badge: &PrBadge, + muted: Hsla, + dark: bool, + on_list: impl Fn(&mut App) + 'static, +) -> gpui::Stateful { + let tone = pr_color(badge.state, dark).unwrap_or(muted); + let anchor = Rc::new(Cell::new(Bounds::default())); + let lines = badge_lines(badge, &row.pr_lines); + let popup_anchor = anchor.clone(); + let url = badge.url.clone().filter(|_| !badge.opens_list); + let control = h_flex() + .id(gpui::SharedString::from(format!("pr-badge-{}", row.id))) + .relative() + .flex_shrink_0() + .gap_1() + .cursor_pointer() + .child( + canvas(move |bounds, _, _| anchor.set(bounds), |_, _, _, _| {}) + .absolute() + .size_full(), + ) + .child(Icon::new(pr_icon(badge)).size(px(12.)).text_color(tone)) + .child(div().text_color(muted).child(badge.text.clone())) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(move |_, _, cx| { + cx.stop_propagation(); + match &url { + Some(url) => cx.open_url(url), + None => on_list(cx), + } + }); + if lines.is_empty() { + return control; + } + control.hoverable_tooltip(move |window, cx| { + let room = f32::from(popup_anchor.get().top()); + let height = popup_height(lines.len(), room); + let place = popup_offset(popup_anchor.get(), window.mouse_position(), height); + let lines = lines.clone(); + cx.new(|_| PrPopup { + lines, + place, + height, + }) + .into() + }) +} + /// What `SidebarThreadTooltip` shows for one thread, resolved when the row is built. #[derive(Clone)] struct ThreadCard { @@ -4838,32 +5042,13 @@ impl Render for ThreadHoverCard { .text_xs() .text_color(muted) .children(card.pr_lines.iter().map(|line| { - let tone = pr_color(line.state, dark).unwrap_or(muted); - h_flex() - .min_w_0() - .items_center() - .gap_2() - .pl(px(line.depth.min(3) as f32 * 12.)) - .child(glyph(pr_state_icon(line.state), tone)) - .child( - div() - .flex_shrink_0() - .font_family(theme.mono_font_family.clone()) - .child(format!("#{}", line.number)), - ) - .child(label(text, line.title.clone())) - .children(line.stack.map(|(kind, size)| { - let word = match kind { - ChainKind::Native => "stack", - ChainKind::Derived => "chain", - }; - div() - .ml_auto() - .flex_shrink_0() - .pl_1() - .text_size(px(10.)) - .child(format!("{word} \u{b7} {size}")) - })) + pr_line_row( + line, + theme.mono_font_family.clone(), + muted, + foreground, + dark, + ) })) }); div().relative().left(self.place.x).top(self.place.y).child( @@ -4897,6 +5082,12 @@ fn project_name<'a>(row: &'a ThreadRow, entry: Option<&'a ProjectEntry>) -> &'a entry.map_or(row.project.as_str(), |entry| entry.title.as_str()) } +/// What a row takes from its owner: the hover card's anchor and the interactive PR badge. +struct RowParts { + anchor: Rc>>, + pr_control: Option>, +} + /// The web's card `SidebarThreadRow`. Snooze and Settle appear on hover, as in the web, but do /// nothing yet. fn thread_row( @@ -4905,9 +5096,10 @@ fn thread_row( provider: Option, selected: bool, sidebar_width: f32, - anchor: Rc>>, + parts: RowParts, cx: &App, ) -> gpui::Stateful { + let RowParts { anchor, pr_control } = parts; let theme = cx.theme(); let dark = theme.is_dark(); let (muted, foreground) = (theme.muted_foreground, theme.foreground); @@ -5031,14 +5223,7 @@ fn thread_row( ) }), )) - .children(row.pr.as_ref().map(|badge| { - let tone = pr_color(badge.state, dark).unwrap_or(muted); - h_flex() - .flex_shrink_0() - .gap_1() - .child(Icon::new(pr_icon(badge)).size(px(12.)).text_color(tone)) - .child(div().text_color(muted).child(badge.text.clone())) - })) + .children(pr_control) .children(provider), ), ) @@ -5233,6 +5418,8 @@ mod tests { state: PrState::Open, stack: false, text: "19".to_owned(), + url: None, + opens_list: false, }; let plain = branch_budget(300., true, None, true); assert!(branch_budget(300., true, Some(&badge), true) < plain); From 5ade42075fdbc5a6e4a50524aa40b4eff9a27285 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 22:59:16 -0500 Subject: [PATCH 50/82] fix(desktop-gpui): sidebar update control shows the web's unavailable state --- apps/desktop-gpui/src/ui.rs | 48 ++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 14 deletions(-) diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 0ff1f5cb3d8f..1c5489d8c2e7 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -1773,31 +1773,22 @@ impl MainView { "settings", Icon::new(IconName::Settings), "Settings", - false, cx, )) .child(footer_button( "pull-requests", Icon::new(Icons::GitPullRequest), "Pull requests", - false, cx, )) .child(footer_button( "usage", Icon::new(Icons::ChartNoAxesColumn), "Usage", - false, cx, )) .child(div().flex_1()) - .child(footer_button( - "update", - Icon::new(Icons::CircleArrowUp), - "Update available", - true, - cx, - )) + .child(update_button(cx)) } /// The web's `SidebarThreadHeader`: search, then the project filter, add project and new thread. @@ -4182,12 +4173,11 @@ fn icon_button( .child(icon) } -/// The sidebar footer's `size-8` menu button, `rounded-full` for the update control. +/// The sidebar footer's `size-8` menu button. fn footer_button( id: &'static str, icon: Icon, label: &'static str, - round: bool, cx: &App, ) -> gpui::Stateful { let theme = cx.theme(); @@ -4199,8 +4189,7 @@ fn footer_button( .flex() .items_center() .justify_center() - .when(round, |button| button.rounded_full()) - .when(!round, |button| button.rounded(px(6.))) + .rounded(px(6.)) .text_color(idle) .cursor_pointer() .hover(move |style| style.bg(hover_bg).text_color(hover_fg)) @@ -4208,6 +4197,37 @@ fn footer_button( .child(icon.size_4()) } +/// The web's idle `SidebarUpdatePill` with no update state: this build has no updater, so +/// `getDesktopUpdateIdleTooltip(null)` and `canCheckForUpdate(null)` make it a dimmed, inert refresh. +fn update_button(cx: &App) -> gpui::Stateful { + use gpui_kit_assets::IconName as Icons; + const TITLE: &str = "Updates unavailable"; + const DETAIL: &str = "Automatic updates are not available in this build."; + div() + .id("update") + .flex_shrink_0() + .size_8() + .flex() + .items_center() + .justify_center() + .rounded_full() + .text_color(cx.theme().muted_foreground) + .opacity(0.6) + .cursor_not_allowed() + .tooltip(|window, cx| { + Tooltip::element(|_, cx| { + v_flex() + .child(div().font_weight(FontWeight::MEDIUM).child(TITLE)) + .child(div().text_color(cx.theme().muted_foreground).child(DETAIL)) + }) + .text_xs() + .py_1() + .rounded(px(6.)) + .build(window, cx) + }) + .child(Icon::new(Icons::RefreshCw).size_4()) +} + /// The web's `ComposerPrimaryActions` send button: stage art in dev builds, else `message-action`. fn send_button(primary: Primary, hovered: bool, cx: &App) -> gpui::Stateful { let enabled = !matches!(primary, Primary::Send { enabled: false }); From c80ab81d66c2f529dc827ec458fc2f4458b43367 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 23:03:04 -0500 Subject: [PATCH 51/82] fix(desktop-gpui): update control uses the desktop's no-feed reason --- apps/desktop-gpui/src/ui.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 1c5489d8c2e7..b3f89c1dc22c 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -4197,12 +4197,13 @@ fn footer_button( .child(icon.size_4()) } -/// The web's idle `SidebarUpdatePill` with no update state: this build has no updater, so -/// `getDesktopUpdateIdleTooltip(null)` and `canCheckForUpdate(null)` make it a dimmed, inert refresh. +/// The web's idle `SidebarUpdatePill`. This build has no update feed, which the desktop reports as +/// `status: "disabled"` (`resolveDisabledReason` in DesktopUpdates.ts): a dimmed, inert refresh. fn update_button(cx: &App) -> gpui::Stateful { use gpui_kit_assets::IconName as Icons; const TITLE: &str = "Updates unavailable"; - const DETAIL: &str = "Automatic updates are not available in this build."; + const DETAIL: &str = + "Automatic updates are not available because no update feed is configured."; div() .id("update") .flex_shrink_0() From d133f0f324ad9fdf1fe524075ccecd42bc1757df Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 23:57:35 -0500 Subject: [PATCH 52/82] feat(desktop-gpui): model picker logic for effort, speed, favorites and ranking --- apps/desktop-gpui/src/connection.rs | 11 +- apps/desktop-gpui/src/main.rs | 1 + apps/desktop-gpui/src/model_picker.rs | 589 ++++++++++++++++++++++++++ apps/desktop-gpui/src/providers.rs | 569 ++++++++++++++++++++----- apps/desktop-gpui/src/rpc.rs | 9 + apps/desktop-gpui/src/send.rs | 47 +- 6 files changed, 1124 insertions(+), 102 deletions(-) create mode 100644 apps/desktop-gpui/src/model_picker.rs diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 5002c8265ea5..0b288b3e45e6 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -7,10 +7,10 @@ use crate::projects::MachineKind; use crate::providers::Catalog; use crate::rpc::{ Event, GitRunStackedAction, OrchestrationDispatchCommand, OrchestrationLaunchThread, - PING_FRAME, ProjectsMutate, ReviewGetDiffPreview, ServerGetConfig, Session, ShellOpenInEditor, - SubscribeShell, SubscribeThread, SubscribeVcsStatus, TerminalAttach, TerminalClose, - TerminalResize, TerminalWrite, VcsCreateRef, VcsListRefs, VcsPull, VcsRefreshStatus, - VcsSwitchRef, + PING_FRAME, ProjectsMutate, ReviewGetDiffPreview, ServerGetConfig, ServerUpdateSettings, + Session, ShellOpenInEditor, SubscribeShell, SubscribeThread, SubscribeVcsStatus, + TerminalAttach, TerminalClose, TerminalResize, TerminalWrite, VcsCreateRef, VcsListRefs, + VcsPull, VcsRefreshStatus, VcsSwitchRef, }; use crate::shell::ShellState; use crate::status::{CallEvent, Status, StatusSender, publish}; @@ -110,6 +110,7 @@ pub enum Call { RefreshStatus, LaunchThread, DispatchCommand, + UpdateSettings, /// A stream: progress events, then the result. RunStackedAction, } @@ -147,6 +148,7 @@ impl Call { Self::RefreshStatus => "refresh the status", Self::LaunchThread => "start the thread", Self::DispatchCommand => "send the command", + Self::UpdateSettings => "update the settings", Self::RunStackedAction => "run the action", } } @@ -164,6 +166,7 @@ impl Call { Self::RefreshStatus => session.request::(payload), Self::LaunchThread => session.request::(payload), Self::DispatchCommand => session.request::(payload), + Self::UpdateSettings => session.request::(payload), Self::RunStackedAction => session.request::(payload), } } diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 7af81be99209..5e3e9fb12cc4 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -11,6 +11,7 @@ mod environments; mod git_actions; mod history; mod minimap; +mod model_picker; mod pairing; mod projects; mod providers; diff --git a/apps/desktop-gpui/src/model_picker.rs b/apps/desktop-gpui/src/model_picker.rs new file mode 100644 index 000000000000..7bbfc7b2c8ae --- /dev/null +++ b/apps/desktop-gpui/src/model_picker.rs @@ -0,0 +1,589 @@ +//! The model picker's derived state, ported from `ModelPickerContent.tsx`, `modelPickerSearch.ts` +//! and `@t3tools/shared/searchRanking`. The view only lays out what these functions return. + +use crate::providers::{PickerInstance, PickerModel}; + +/// `MODEL_PICKER_JUMP_KEYBINDING_COMMANDS`: the first nine usable rows take Cmd+1 to Cmd+9. +pub const JUMP_KEYS: usize = 9; + +const FAVORITE_BOOST: i32 = 24; +const EXACT_NAME: i32 = -1000; + +/// A rail entry of the picker's sidebar. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Tab { + Favorites, + Instance(String), +} + +/// Which rail entry the last model was picked from, so the picker reopens there. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Origin { + Favorites, + Instance, +} + +/// The column that takes the arrow keys. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Focus { + #[default] + List, + Sidebar, + Effort, +} + +/// The open picker's view state: the shown rail entry, the highlighted item of the focused +/// column, and the entry the last model came from. +#[derive(Debug, Default)] +pub struct State { + pub tab: Option, + pub expanded: bool, + pub highlight: usize, + pub focus: Focus, + pub effort: usize, + pub origin: Option, +} + +/// `initialModelPickerProvider` without the locked-provider cases, which the desktop has none of. +pub fn initial_tab( + active_instance: &str, + active_visible: bool, + has_favorites: bool, + last_origin: Option, +) -> Tab { + if !has_favorites { + return Tab::Instance(active_instance.to_owned()); + } + if !active_visible || last_origin == Some(Origin::Favorites) { + Tab::Favorites + } else { + Tab::Instance(active_instance.to_owned()) + } +} + +/// One listed model with the instance it runs on. +#[derive(Clone, Copy, Debug)] +pub struct Row<'a> { + pub instance: &'a PickerInstance, + pub model: &'a PickerModel, +} + +impl Row<'_> { + pub fn usable(&self) -> bool { + self.instance.unavailable.is_none() + } +} + +/// What the model column lists. +#[derive(Debug)] +pub struct Listing<'a> { + pub rows: Vec>, + /// The trailing "Show more" / "Show less" item: `Some(expanded)` when older models exist. + pub older: Option, +} + +impl Listing<'_> { + /// Navigable items: every row, plus the older-models toggle. + pub fn items(&self) -> usize { + self.rows.len() + usize::from(self.older.is_some()) + } + + /// Row indexes that take Cmd+1.. in order. + pub fn jump_rows(&self) -> Vec { + self.rows + .iter() + .enumerate() + .filter(|(_, row)| row.usable()) + .map(|(index, _)| index) + .take(JUMP_KEYS) + .collect() + } +} + +/// The rows for `tab`, or the ranked matches of `query` across every instance. Starred models lead +/// an instance's list, and a collapsed instance hides its older models behind the toggle. +pub fn listing<'a>( + instances: &'a [PickerInstance], + tab: &Tab, + query: &str, + expanded: bool, +) -> Listing<'a> { + if !query.trim().is_empty() { + return Listing { + rows: rank_models(instances, query), + older: None, + }; + } + let all = |instance: &'a PickerInstance| { + instance + .models + .iter() + .map(move |model| Row { instance, model }) + }; + match tab { + Tab::Favorites => Listing { + rows: instances + .iter() + .flat_map(all) + .filter(|row| row.model.favorite) + .collect(), + older: None, + }, + Tab::Instance(id) => { + let mut rows: Vec = instances + .iter() + .filter(|instance| &instance.instance_id == id) + .flat_map(all) + .collect(); + rows.sort_by_key(|row| !row.model.favorite); + let (older, mut current): (Vec, Vec) = rows + .into_iter() + .partition(|row| row.model.legacy && !row.model.favorite); + let toggle = (!older.is_empty()).then_some(expanded); + if expanded { + current.extend(older); + } + Listing { + rows: current, + older: toggle, + } + } + } +} + +/// Whether the older models start expanded: when the active model is one of them. +pub fn starts_expanded(instances: &[PickerInstance], instance_id: &str, slug: &str) -> bool { + instances + .iter() + .filter(|instance| instance.instance_id == instance_id) + .flat_map(|instance| &instance.models) + .any(|model| model.slug == slug && model.legacy && !model.favorite) +} + +/// The rail entry after `current` in `direction` (+1 or -1), wrapping, over Favorites and the +/// usable instances (`adjacentModelPickerProvider`). +pub fn adjacent_tab(instances: &[PickerInstance], current: &Tab, direction: isize) -> Tab { + let tabs: Vec = std::iter::once(Tab::Favorites) + .chain( + instances + .iter() + .filter(|instance| instance.unavailable.is_none()) + .map(|instance| Tab::Instance(instance.instance_id.clone())), + ) + .collect(); + match tabs.iter().position(|tab| tab == current) { + Some(index) => tabs[step(index, tabs.len(), direction)].clone(), + None if direction > 0 => tabs[0].clone(), + None => tabs[tabs.len() - 1].clone(), + } +} + +/// The item after `current` in `delta` steps, wrapping around `count` items. +pub fn step(current: usize, count: usize, delta: isize) -> usize { + if count == 0 { + return 0; + } + (current as isize + delta).rem_euclid(count as isize) as usize +} + +/// The picker's search: every token must match a field; exact names first, then the tightest +/// matches with starred models ahead, then list order (`rankModelPickerSearch`). +pub fn rank_models<'a>(instances: &'a [PickerInstance], query: &str) -> Vec> { + let mut ranked: Vec<(i32, bool, usize, Row<'a>)> = Vec::new(); + let rows = instances.iter().flat_map(|instance| { + instance + .models + .iter() + .map(move |model| Row { instance, model }) + }); + for (order, row) in rows.enumerate() { + if let Some(score) = score(row, query) { + ranked.push((score, row.model.favorite, order, row)); + } + } + ranked.sort_by_key(|(score, favorite, order, _)| (*score, !*favorite, *order)); + ranked.into_iter().map(|(.., row)| row).collect() +} + +fn score(row: Row, query: &str) -> Option { + let model = row.model; + let normalize = |text: &str| text.trim().to_lowercase(); + let tokens: Vec = normalize(query) + .split_whitespace() + .map(str::to_owned) + .collect(); + if tokens.is_empty() { + return Some(0); + } + let query = tokens.join(" "); + let collapse = |text: &str| { + normalize(text) + .split_whitespace() + .collect::>() + .join(" ") + }; + if query == collapse(&model.name) + || model.short_name.as_deref().map(collapse) == Some(query.clone()) + { + return Some(EXACT_NAME); + } + let parts: Vec<&str> = [ + Some(model.name.as_str()), + model.short_name.as_deref(), + model.sub_provider.as_deref(), + Some(row.instance.driver.as_str()), + Some(row.instance.name.as_str()), + ] + .into_iter() + .flatten() + .filter(|part| !part.is_empty()) + .collect(); + let joined = normalize(&parts.join(" ")); + let fields: Vec = parts + .iter() + .map(|part| normalize(part)) + .chain(std::iter::once(joined)) + .collect(); + let mut total = 0; + for token in &tokens { + total += fields + .iter() + .enumerate() + .filter_map(|(index, field)| token_score(field, token, index as i32 * 10)) + .min()?; + } + Some(if model.favorite { + total - FAVORITE_BOOST + } else { + total + }) +} + +/// `scoreQueryMatch` at a field's base, position-insensitive, with the fuzzy tier for tokens of +/// three or more characters. +fn token_score(field: &str, token: &str, base: i32) -> Option { + if field.is_empty() { + return None; + } + if field == token { + return Some(base); + } + if field.starts_with(token) { + return Some(base + 2); + } + if [" ", "-", "_", "/"] + .iter() + .any(|marker| field.contains(&format!("{marker}{token}"))) + { + return Some(base + 4); + } + if field.contains(token) { + return Some(base + 6); + } + if token.chars().count() >= 3 { + return subsequence_penalty(field, token).map(|penalty| base + 100 + penalty); + } + None +} + +/// `scoreSubsequenceMatch` with `ignorePosition`: gaps between matched characters and the span. +fn subsequence_penalty(value: &str, query: &str) -> Option { + let wanted: Vec = query.chars().collect(); + let mut next = 0; + let (mut first, mut previous) = (None, None); + let mut gaps = 0; + for (index, ch) in value.chars().enumerate() { + if ch != wanted[next] { + continue; + } + first.get_or_insert(index); + if let Some(previous) = previous { + gaps += (index - previous - 1) as i32; + } + previous = Some(index); + next += 1; + if next == wanted.len() { + let span = (index - first? + 1) as i32 - wanted.len() as i32; + return Some(gaps * 3 + span); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::providers::Mark; + + fn instance(id: &str, driver: &str, name: &str, models: Vec) -> PickerInstance { + PickerInstance { + instance_id: id.to_owned(), + mark: Mark { + driver: driver.to_owned(), + display_name: name.to_owned(), + badge: None, + }, + name: name.to_owned(), + unavailable: None, + models, + driver: driver.to_owned(), + } + } + + fn model(name: &str) -> PickerModel { + PickerModel { + slug: name.to_lowercase().replace(' ', "-"), + label: name.to_owned(), + title: name.to_owned(), + name: name.to_owned(), + short_name: None, + sub_provider: None, + favorite: false, + legacy: false, + new: false, + } + } + + fn catalog() -> Vec { + let names = |names: &[&str]| names.iter().map(|name| model(name)).collect::>(); + vec![ + instance( + "claudeAgent", + "claudeAgent", + "Claude", + names(&[ + "Claude Opus 5.5", + "Claude Sonnet 5.5", + "Claude Opus 5", + "Claude Opus 4.8", + "Claude Opus 4.7", + "Claude Opus 4.6", + "Claude Opus 4.5", + ]), + ), + instance( + "codex", + "codex", + "Codex", + names(&["GPT-6.1 Sol", "GPT-6 Astra", "GPT-6 Sol", "GPT-5.5"]), + ), + instance( + "opencode", + "opencode", + "OpenCode", + ["Kimi K3", "Kimi K2.5", "Kimi K2"] + .iter() + .map(|name| PickerModel { + sub_provider: Some("Moonshot".to_owned()), + ..model(name) + }) + .collect(), + ), + ] + } + + fn search(instances: &[PickerInstance], query: &str) -> Vec { + rank_models(instances, query) + .iter() + .map(|row| row.model.name.clone()) + .collect() + } + + #[test] + fn a_version_token_keeps_the_newest_first() { + let instances = catalog(); + assert_eq!( + search(&instances, "opus 4"), + [ + "Claude Opus 4.8", + "Claude Opus 4.7", + "Claude Opus 4.6", + "Claude Opus 4.5" + ] + ); + assert_eq!(search(&instances, "opus 5."), ["Claude Opus 5.5"]); + } + + #[test] + fn an_exact_name_outranks_models_that_extend_it() { + let instances = catalog(); + assert_eq!( + search(&instances, "gpt-6 sol")[..2], + ["GPT-6 Sol", "GPT-6.1 Sol"] + ); + assert_eq!( + search(&instances, "claude opus 5")[..2], + ["Claude Opus 5", "Claude Opus 5.5"] + ); + } + + #[test] + fn the_provider_matches_when_the_name_does_not() { + assert_eq!( + search(&catalog(), "moonshot"), + ["Kimi K3", "Kimi K2.5", "Kimi K2"] + ); + } + + #[test] + fn a_favorite_leads_its_family_but_not_clearly_better_matches() { + let mut instances = catalog(); + instances[0].models[6].favorite = true; + assert_eq!( + search(&instances, "opus")[..3], + ["Claude Opus 4.5", "Claude Opus 5.5", "Claude Opus 5"] + ); + let exact = score( + Row { + instance: &instance("cursor", "cursor", "Cursor", vec![]), + model: &model("Opus 4.7"), + }, + "opus 4.7", + ) + .unwrap(); + let favorite = score( + Row { + instance: &instances[0], + model: &PickerModel { + favorite: true, + ..model("Claude Opus 4.7") + }, + }, + "opus 4.7", + ) + .unwrap(); + assert!(exact < favorite); + } + + #[test] + fn fuzzy_matches_need_three_characters_and_rank_below_plain_ones() { + let instances = catalog(); + assert!(search(&instances, "kk").is_empty()); + assert_eq!(search(&instances, "kmi")[0], "Kimi K3"); + let plain = score(rank_models(&instances, "kimi")[0], "kimi").unwrap(); + let fuzzy = score(rank_models(&instances, "kmi")[0], "kmi").unwrap(); + assert!(plain < fuzzy); + assert!(search(&instances, "zzz").is_empty()); + } + + #[test] + fn reopens_on_the_tab_the_last_model_came_from() { + let active = "claudeAgent_personal"; + let instance = Tab::Instance(active.to_owned()); + assert_eq!( + initial_tab(active, true, true, Some(Origin::Favorites)), + Tab::Favorites + ); + assert_eq!( + initial_tab(active, true, true, Some(Origin::Instance)), + instance + ); + assert_eq!(initial_tab(active, true, true, None), instance); + assert_eq!( + initial_tab(active, true, false, Some(Origin::Favorites)), + instance + ); + assert_eq!( + initial_tab(active, false, true, Some(Origin::Instance)), + Tab::Favorites + ); + } + + #[test] + fn favorites_lead_and_older_models_hide_behind_the_toggle() { + let mut instances = catalog(); + instances[0].models[3].legacy = true; + instances[0].models[4].legacy = true; + instances[0].models[4].favorite = true; + let tab = Tab::Instance("claudeAgent".to_owned()); + let names = |listing: &Listing| { + listing + .rows + .iter() + .map(|row| row.model.name.as_str().to_owned()) + .collect::>() + }; + let collapsed = listing(&instances, &tab, "", false); + assert_eq!(collapsed.older, Some(false)); + assert_eq!( + names(&collapsed), + [ + "Claude Opus 4.7", + "Claude Opus 5.5", + "Claude Sonnet 5.5", + "Claude Opus 5", + "Claude Opus 4.6", + "Claude Opus 4.5" + ] + ); + assert_eq!(collapsed.items(), 7); + let expanded = listing(&instances, &tab, "", true); + assert_eq!(expanded.older, Some(true)); + assert_eq!(names(&expanded).last().unwrap(), "Claude Opus 4.8"); + assert!(starts_expanded( + &instances, + "claudeAgent", + "claude-opus-4.8" + )); + assert!(!starts_expanded( + &instances, + "claudeAgent", + "claude-opus-4.7" + )); + } + + #[test] + fn the_favorites_tab_lists_stars_across_instances_and_search_ignores_tabs() { + let mut instances = catalog(); + instances[1].models[0].favorite = true; + instances[0].models[1].favorite = true; + let favorites = listing(&instances, &Tab::Favorites, "", false); + let names: Vec<&str> = favorites + .rows + .iter() + .map(|row| row.model.name.as_str()) + .collect(); + assert_eq!(names, ["Claude Sonnet 5.5", "GPT-6.1 Sol"]); + assert_eq!(favorites.older, None); + let searched = listing(&instances, &Tab::Favorites, "kimi", false); + assert_eq!(searched.rows.len(), 3); + } + + #[test] + fn jump_keys_number_the_first_nine_usable_rows() { + let mut instances = catalog(); + instances[0].unavailable = Some("Claude: Unavailable.".to_owned()); + let all = listing(&instances, &Tab::Favorites, "o", false); + let jumps = all.jump_rows(); + assert!(jumps.len() <= JUMP_KEYS); + assert!(jumps.iter().all(|index| all.rows[*index].usable())); + let codex = listing(&instances, &Tab::Instance("codex".to_owned()), "", false); + assert_eq!(codex.jump_rows(), [0, 1, 2, 3]); + } + + #[test] + fn the_rail_and_the_list_wrap_around() { + let mut instances = catalog(); + instances[2].unavailable = Some("OpenCode: Unavailable.".to_owned()); + let claude = Tab::Instance("claudeAgent".to_owned()); + assert_eq!( + adjacent_tab(&instances, &Tab::Favorites, -1), + Tab::Instance("codex".to_owned()) + ); + assert_eq!( + adjacent_tab(&instances, &claude, 1), + Tab::Instance("codex".to_owned()) + ); + assert_eq!( + adjacent_tab(&instances, &Tab::Instance("codex".to_owned()), 1), + Tab::Favorites + ); + assert_eq!( + adjacent_tab(&instances, &Tab::Instance("gone".to_owned()), 1), + Tab::Favorites + ); + assert_eq!(step(0, 3, -1), 2); + assert_eq!(step(2, 3, 1), 0); + assert_eq!(step(0, 0, 1), 0); + } +} diff --git a/apps/desktop-gpui/src/providers.rs b/apps/desktop-gpui/src/providers.rs index f980c7d88fad..2d81aed22465 100644 --- a/apps/desktop-gpui/src/providers.rs +++ b/apps/desktop-gpui/src/providers.rs @@ -85,6 +85,8 @@ struct Choice { label: String, #[serde(default)] is_default: bool, + #[serde(default)] + description: Option, } #[derive(Clone, Debug, Deserialize, PartialEq, Eq)] @@ -97,11 +99,15 @@ enum Descriptor { Select { id: String, #[serde(default)] + label: String, + #[serde(default)] options: Vec, #[serde(default)] current_value: Option, #[serde(default)] role: Option, + #[serde(default)] + prompt_injected_values: Vec, }, Boolean { id: String, @@ -134,6 +140,10 @@ struct Model { #[serde(default)] is_default: bool, #[serde(default)] + badge: Option, + #[serde(default)] + is_legacy: bool, + #[serde(default)] capabilities: Option, } @@ -204,6 +214,18 @@ impl Selection { self.instance_id.as_deref().or(self.provider.as_deref()) } + /// The `{id, value}` selections, from the array form or the legacy object form. + fn explicit(&self) -> Vec { + match &self.options { + Value::Array(items) => items.clone(), + Value::Object(map) => map + .iter() + .map(|(id, value)| serde_json::json!({"id": id, "value": value})) + .collect(), + _ => Vec::new(), + } + } + /// Option values by id, from the array form or the legacy object form. fn option(&self, id: &str) -> Option { let text = |value: &Value| match value { @@ -222,9 +244,18 @@ impl Selection { } } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq)] +struct Favorite { + provider: String, + model: String, +} + #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] struct Settings { + /// `None` on servers that predate server-side favorites. + #[serde(default)] + favorites: Option>, #[serde(default)] provider_instances: HashMap, #[serde(default)] @@ -278,6 +309,8 @@ pub struct Mark { pub struct Pick { pub instance_id: String, pub model: String, + /// The `{id, value}` option selections the user set, as `modelSelection.options` carries them. + pub options: Vec, } /// One model row of the picker. @@ -287,7 +320,14 @@ pub struct PickerModel { pub label: String, /// The model's full name, for the tooltip. pub title: String, + /// The provider's raw name and short name, which search matches. + pub name: String, + pub short_name: Option, pub sub_provider: Option, + pub favorite: bool, + /// Older models sit behind the list's "Show more" unless starred. + pub legacy: bool, + pub new: bool, } /// One provider instance of the picker's sidebar, with its models. @@ -299,7 +339,7 @@ pub struct PickerInstance { /// What the sidebar says when the instance cannot take a turn. pub unavailable: Option, pub models: Vec, - driver: String, + pub driver: String, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -309,6 +349,12 @@ pub struct Trigger { pub mark: Mark, pub model: String, pub effort: Option, + /// Whether the speed control is on a fast tier, which the chip marks with a bolt. + pub fast: bool, + /// The effort column and speed control of the model. + pub controls: Controls, + /// The `modelSelection.options` a turn on this model sends. + pub options: Vec, /// The model's full name, for the tooltip. pub title: String, } @@ -338,6 +384,34 @@ impl Catalog { }) } + /// Whether `model` on `instance_id` is starred. + pub fn is_favorite(&self, instance_id: &str, model: &str) -> bool { + self.settings.favorites.as_ref().is_some_and(|favorites| { + favorites + .iter() + .any(|fav| fav.provider == instance_id && fav.model == model) + }) + } + + pub fn has_favorites(&self) -> bool { + self.settings + .favorites + .as_ref() + .is_some_and(|favorites| !favorites.is_empty()) + } + + /// Stars or unstars a model in the local copy, like the server's `setModelFavorites` reducer. + pub fn set_favorite(&mut self, instance_id: &str, model: &str, favorite: bool) { + let favorites = self.settings.favorites.get_or_insert_with(Vec::new); + favorites.retain(|fav| !(fav.provider == instance_id && fav.model == model)); + if favorite { + favorites.push(Favorite { + provider: instance_id.to_owned(), + model: model.to_owned(), + }); + } + } + /// The editor ids the server found on its machine (`server.getConfig` `availableEditors`). pub fn available_editors(&self) -> &[String] { &self.available_editors @@ -410,7 +484,12 @@ impl Catalog { slug: model.slug.clone(), label: trigger_model_name(model, &provider.driver), title: display_model_name(&model.name, model.sub_provider.as_deref()), + name: model.name.clone(), + short_name: model.short_name.clone(), sub_provider: model.sub_provider.clone(), + favorite: self.is_favorite(&provider.instance_id, &model.slug), + legacy: model.is_legacy, + new: model.badge.as_deref() == Some("new"), }) .collect(), driver: provider.driver.clone(), @@ -437,6 +516,7 @@ impl Catalog { .then(|| Pick { instance_id: instance_id.to_owned(), model: slug.to_owned(), + options: Vec::new(), }) } @@ -458,7 +538,7 @@ impl Catalog { instance_id: Some(pick.instance_id.clone()), provider: None, model: pick.model.clone(), - options: Value::Null, + options: Value::Array(pick.options.clone()), }; Some((entry, selection)) }); @@ -483,11 +563,16 @@ impl Catalog { "opencode" | "antigravity" => None, _ => provider.models.first(), }); - let (model, title, effort) = match shown { + let requested = requested.filter(|selection| { + resolve_model(&provider.models, &selection.model).map(|model| &model.slug) + == Some(&slug) + }); + let (model, title, effort, controls) = match shown { Some(model) => ( trigger_model_name(model, &provider.driver), display_model_name(&model.name, model.sub_provider.as_deref()), effort_word(model, &provider.driver, requested), + Controls::of(model, &provider.driver, requested), ), None => { let label = if slug.is_empty() { @@ -495,15 +580,21 @@ impl Catalog { } else { slug.clone() }; - (label.clone(), label, None) + (label.clone(), label, None, Controls::default()) } }; + let options = shown + .map(|model| sent_options(model, requested)) + .unwrap_or_default(); Some(Trigger { instance_id: provider.instance_id.clone(), slug, mark: mark(&entries, entry), model, effort, + fast: controls.fast(), + controls, + options, title, }) } @@ -590,85 +681,6 @@ fn unavailable_note(name: &str, provider: &Provider) -> String { format!("{name}: {kind}.") } -/// The picker's search: every token must match a field; exact names first, then the tightest -/// matches in list order (`modelPickerSearch.ts` without its fuzzy tier). -pub fn rank_models<'a>( - instances: &'a [PickerInstance], - query: &str, -) -> Vec<(&'a PickerInstance, &'a PickerModel)> { - let tokens: Vec = query.split_whitespace().map(str::to_lowercase).collect(); - let normalized = tokens.join(" "); - let mut ranked: Vec<(i32, usize, &PickerInstance, &PickerModel)> = Vec::new(); - let mut order = 0; - for instance in instances { - for model in &instance.models { - order += 1; - let fields = [ - model.title.to_lowercase(), - model.label.to_lowercase(), - model - .sub_provider - .clone() - .unwrap_or_default() - .to_lowercase(), - instance.driver.to_lowercase(), - instance.name.to_lowercase(), - ]; - if tokens.is_empty() { - ranked.push((0, order, instance, model)); - continue; - } - if fields[..2].contains(&normalized) { - ranked.push((-1000, order, instance, model)); - continue; - } - let mut score = 0; - let mut matched = true; - for token in &tokens { - match fields - .iter() - .enumerate() - .filter_map(|(index, field)| { - token_score(field, token).map(|tier| index as i32 * 10 + tier) - }) - .min() - { - Some(best) => score += best, - None => { - matched = false; - break; - } - } - } - if matched { - ranked.push((score, order, instance, model)); - } - } - } - ranked.sort_by_key(|(score, order, ..)| (*score, *order)); - ranked - .into_iter() - .map(|(_, _, instance, model)| (instance, model)) - .collect() -} - -fn token_score(field: &str, token: &str) -> Option { - if field == token { - Some(0) - } else if field.starts_with(token) { - Some(2) - } else if field - .split(|ch: char| !ch.is_alphanumeric()) - .any(|word| word.starts_with(token)) - { - Some(4) - } else if field.contains(token) { - Some(6) - } else { - None - } -} - fn config_enabled(config: &InstanceConfig, driver: &str) -> bool { let flag = config.config["enabled"].as_bool(); if config.enabled == Some(false) || flag == Some(false) { @@ -1103,6 +1115,235 @@ fn effort_word(model: &Model, driver: &str, selection: Option<&Selection>) -> Op } } +/// One row of the effort column, or one segment of the speed control. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Level { + /// What `modelSelection.options` carries for this level: a choice id or a flag. + pub value: Value, + pub label: String, + pub is_default: bool, + pub selected: bool, + pub description: Option, +} + +/// A model option the picker edits: a select's choices, or a flag as Off and On. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Control { + pub id: String, + pub label: String, + pub flag: bool, + pub levels: Vec, +} + +impl Control { + pub fn current(&self) -> Option<&Level> { + self.levels.iter().find(|level| level.selected) + } + + /// The level after the selected one, wrapping: what clicking a menu-less option row picks. + pub fn next(&self) -> Option<&Level> { + let at = self.levels.iter().position(|level| level.selected); + self.levels + .get(at.map_or(0, |index| (index + 1) % self.levels.len())) + } +} + +/// What the picker's effort column and footer offer for the selected model +/// (`ComposerModelEffort.tsx`). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Controls { + /// The reasoning control, listed under the column header. + pub effort: Option, + /// The remaining options, one row each below the levels. + pub others: Vec, + /// The footer's speed segments: a service tier, or Standard and Fast. + pub speed: Option, +} + +impl Controls { + fn of(model: &Model, driver: &str, selection: Option<&Selection>) -> Self { + let Some(capabilities) = &model.capabilities else { + return Self::default(); + }; + let descriptors = &capabilities.option_descriptors; + let selects = descriptors + .iter() + .filter(|descriptor| matches!(descriptor, Descriptor::Select { .. })); + let flags = descriptors + .iter() + .filter(|descriptor| matches!(descriptor, Descriptor::Boolean { .. })); + let (speeds, column): (Vec<&Descriptor>, Vec<&Descriptor>) = selects + .chain(flags) + .partition(|descriptor| is_speed_control(descriptor, driver)); + let effort_at = column + .iter() + .position(|descriptor| is_reasoning(descriptor)); + let mut controls: Vec> = column + .iter() + .map(|descriptor| control(descriptor, selection, false)) + .collect(); + let effort = effort_at.and_then(|index| controls[index].take()); + Self { + effort, + others: controls.into_iter().flatten().collect(), + speed: speeds + .first() + .and_then(|descriptor| control(descriptor, selection, true)), + } + } + + /// Whether the speed control sits on a fast tier. + fn fast(&self) -> bool { + self.speed + .as_ref() + .and_then(Control::current) + .is_some_and(|level| is_fast_label(&level.label)) + } + + /// What each row of the effort column sets, in order: an option id and its value. + pub fn actions(&self) -> Vec<(String, Value)> { + let effort = self.effort.iter().flat_map(|control| { + control + .levels + .iter() + .map(|level| (control.id.clone(), level.value.clone())) + }); + let others = self.others.iter().filter_map(|control| { + control + .next() + .map(|level| (control.id.clone(), level.value.clone())) + }); + effort.chain(others).collect() + } + + /// The column header: the effort control's name, else "Options", else "Effort". + pub fn title(&self) -> String { + match &self.effort { + Some(effort) if !effort.label.is_empty() => effort.label.clone(), + None if !self.others.is_empty() => "Options".to_owned(), + _ => "Effort".to_owned(), + } + } +} + +pub fn is_fast_label(label: &str) -> bool { + label == "Fast" || label == "Ultrafast" +} + +/// A select's choices (minus the ones a prompt prefix selects) or a flag, with the current one marked. +fn control(descriptor: &Descriptor, selection: Option<&Selection>, speed: bool) -> Option { + match descriptor { + Descriptor::Select { + id, + label, + options, + current_value, + prompt_injected_values, + .. + } => { + let current = selection + .and_then(|selection| selection.option(id)) + .or_else(|| current_value.clone()) + .or_else(|| { + options + .iter() + .find(|option| option.is_default) + .map(|option| option.id.clone()) + }); + let levels: Vec = options + .iter() + .filter(|option| !prompt_injected_values.contains(&option.id)) + .map(|option| Level { + value: Value::String(option.id.clone()), + label: option.label.clone(), + is_default: option.is_default, + selected: current.as_deref() == Some(option.id.as_str()), + description: option.description.clone().filter(|text| !text.is_empty()), + }) + .collect(); + (!levels.is_empty()).then(|| Control { + id: id.clone(), + label: label.clone(), + flag: false, + levels, + }) + } + Descriptor::Boolean { + id, + label, + current_value, + } => { + let on = selection + .and_then(|selection| selection.option(id)) + .map(|value| value == "true") + .or(*current_value) + == Some(true); + let names = if speed { + [(false, "Standard"), (true, "Fast")] + } else { + [(true, "On"), (false, "Off")] + }; + Some(Control { + id: id.clone(), + label: label.clone(), + flag: true, + levels: names + .into_iter() + .map(|(value, name)| Level { + value: Value::Bool(value), + label: name.to_owned(), + is_default: false, + selected: on == value, + description: None, + }) + .collect(), + }) + } + } +} + +/// `options` with `id` set to `value`: the option selections after the user picks a level. +pub fn with_option(options: &[Value], id: &str, value: Value) -> Vec { + let entry = serde_json::json!({"id": id, "value": value}); + let mut next: Vec = options + .iter() + .filter(|option| option["id"] != id) + .cloned() + .collect(); + next.push(entry); + next +} + +/// The options a turn sends: the user's selections for this model's options, plus Fast mode off +/// when the model has that flag and nothing set it (`withImplicitFastModeDefault`). +fn sent_options(model: &Model, selection: Option<&Selection>) -> Vec { + let descriptors = model + .capabilities + .as_ref() + .map(|capabilities| capabilities.option_descriptors.as_slice()) + .unwrap_or_default(); + let known = |id: &str| { + descriptors.iter().any(|descriptor| match descriptor { + Descriptor::Select { id: known, .. } | Descriptor::Boolean { id: known, .. } => { + known == id + } + }) + }; + let mut options: Vec = selection + .map(Selection::explicit) + .unwrap_or_default() + .into_iter() + .filter(|option| option["id"].as_str().is_some_and(known)) + .collect(); + let has_fast_flag = descriptors + .iter() + .any(|descriptor| matches!(descriptor, Descriptor::Boolean { id, .. } if id == "fastMode")); + if has_fast_flag && !options.iter().any(|option| option["id"] == "fastMode") { + options.push(serde_json::json!({"id": "fastMode", "value": false})); + } + options +} + #[cfg(test)] mod tests { use super::*; @@ -1549,6 +1790,7 @@ mod tests { let stale = Pick { instance_id: "ghost".to_owned(), model: "x".to_owned(), + options: Vec::new(), }; assert_eq!( catalog.trigger_for(None, Some(&stale)), @@ -1556,21 +1798,154 @@ mod tests { ); } + fn reasoning_model() -> Value { + json!({ + "slug": "gpt-6", "name": "GPT-6", "isCustom": false, "isDefault": true, + "capabilities": {"optionDescriptors": [ + {"id": "reasoningEffort", "label": "Reasoning", "type": "select", "options": [ + {"id": "low", "label": "Low"}, + {"id": "medium", "label": "Medium", "isDefault": true}, + {"id": "high", "label": "High"}, + {"id": "ultrathink", "label": "Ultrathink"}, + ], "promptInjectedValues": ["ultrathink"]}, + {"id": "fastMode", "label": "Fast Mode", "type": "boolean"}, + {"id": "contextWindow", "label": "Context", "type": "select", "options": [ + {"id": "200k", "label": "200k", "isDefault": true}, + {"id": "1m", "label": "1M"}, + ]}, + ]}, + }) + } + + fn reasoning_catalog(favorites: Value) -> Catalog { + catalog( + vec![provider("codex", "codex", "ready", vec![reasoning_model()])], + json!({"favorites": favorites}), + ) + } + #[test] - fn search_ranks_exact_names_first_and_requires_every_token() { - let catalog = two_instances(); - let instances = catalog.picker(None); - assert_eq!(instances.len(), 2); - let slugs = |query: &str| -> Vec { - rank_models(&instances, query) - .iter() - .map(|(_, model)| model.slug.clone()) - .collect() + fn the_effort_column_lists_levels_and_marks_the_default_and_selection() { + let trigger = reasoning_catalog(json!([])).trigger(None).unwrap(); + let effort = trigger.controls.effort.as_ref().unwrap(); + assert_eq!(trigger.controls.title(), "Reasoning"); + let levels: Vec<_> = effort + .levels + .iter() + .map(|level| (level.label.as_str(), level.is_default, level.selected)) + .collect(); + assert_eq!( + levels, + [ + ("Low", false, false), + ("Medium", true, true), + ("High", false, false) + ] + ); + assert_eq!(trigger.effort.as_deref(), Some("Medium")); + assert_eq!(trigger.controls.others.len(), 1); + assert_eq!(trigger.controls.others[0].id, "contextWindow"); + let actions = trigger.controls.actions(); + assert_eq!(actions.len(), 3 + trigger.controls.others.len()); + assert_eq!(actions[0].1, effort.levels[0].value); + let other = &trigger.controls.others[0]; + assert_eq!( + actions[3], + (other.id.clone(), other.next().unwrap().value.clone()) + ); + } + + #[test] + fn fast_mode_is_a_footer_toggle_and_defaults_off_on_the_wire() { + let catalog = reasoning_catalog(json!([])); + let trigger = catalog.trigger(None).unwrap(); + let speed = trigger.controls.speed.as_ref().unwrap(); + let labels: Vec<_> = speed + .levels + .iter() + .map(|level| level.label.as_str()) + .collect(); + assert_eq!(labels, ["Standard", "Fast"]); + assert!(!trigger.fast); + assert_eq!(trigger.options, [json!({"id": "fastMode", "value": false})]); + + let options = with_option(&trigger.options, "fastMode", json!(true)); + let pick = Pick { + options: with_option(&options, "reasoningEffort", json!("high")), + ..catalog.pick(None, "codex", "gpt-6").unwrap() + }; + let picked = catalog.trigger_for(None, Some(&pick)).unwrap(); + assert!(picked.fast); + assert_eq!(picked.effort.as_deref(), Some("High")); + assert_eq!( + picked.options, + [ + json!({"id": "fastMode", "value": true}), + json!({"id": "reasoningEffort", "value": "high"}), + ] + ); + } + + #[test] + fn a_codex_service_tier_is_the_speed_control_and_a_bolt_marks_fast() { + let tiers = json!({ + "slug": "gpt-6", "name": "GPT-6", "isCustom": false, "isDefault": true, + "capabilities": {"optionDescriptors": [ + {"id": "serviceTier", "label": "Speed", "type": "select", "options": [ + {"id": "default", "label": "Standard", "isDefault": true}, + {"id": "fast", "label": "Fast", "description": "2x speed, increased usage"}, + ]}, + ]}, + }); + let catalog = catalog( + vec![provider("codex", "codex", "ready", vec![tiers])], + json!({}), + ); + let trigger = catalog.trigger(None).unwrap(); + let speed = trigger.controls.speed.as_ref().unwrap(); + assert_eq!( + speed.levels[1].description.as_deref(), + Some("2x speed, increased usage") + ); + assert!(trigger.controls.effort.is_none()); + assert_eq!(trigger.controls.title(), "Effort"); + let pick = Pick { + options: with_option(&[], "serviceTier", json!("fast")), + ..catalog.pick(None, "codex", "gpt-6").unwrap() + }; + assert!(catalog.trigger_for(None, Some(&pick)).unwrap().fast); + } + + #[test] + fn options_a_model_does_not_have_are_not_sent() { + let catalog = reasoning_catalog(json!([])); + let pick = Pick { + options: with_option(&[], "other", json!("x")), + ..catalog.pick(None, "codex", "gpt-6").unwrap() }; - assert_eq!(slugs(""), ["gpt-5", "gpt-5-mini", "opus"]); - assert_eq!(slugs("gpt-5"), ["gpt-5", "gpt-5-mini"]); - assert_eq!(slugs("mini gpt"), ["gpt-5-mini"]); - assert_eq!(slugs("claude"), ["opus"]); - assert!(slugs("zzz").is_empty()); + let sent = catalog.trigger_for(None, Some(&pick)).unwrap().options; + assert!(sent.iter().all(|option| option["id"] != "other")); + } + + #[test] + fn a_model_without_options_has_empty_controls() { + let trigger = two_instances().trigger(None).unwrap(); + assert_eq!(trigger.controls, Controls::default()); + assert!(trigger.options.is_empty()); + assert_eq!(trigger.controls.title(), "Effort"); + } + + #[test] + fn favorites_come_from_the_server_settings_and_toggle_locally() { + let mut starred = reasoning_catalog(json!([{"provider": "codex", "model": "gpt-6"}])); + assert!(starred.has_favorites()); + assert!(starred.picker(None)[0].models[0].favorite); + starred.set_favorite("codex", "gpt-6", false); + assert!(!starred.is_favorite("codex", "gpt-6")); + assert!(!starred.has_favorites()); + starred.set_favorite("codex", "gpt-6", true); + starred.set_favorite("codex", "gpt-6", true); + assert_eq!(starred.settings.favorites.as_ref().unwrap().len(), 1); + assert!(!catalog(vec![], json!({})).has_favorites()); } } diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs index 0a2bbf2360d8..3c01ccc72a49 100644 --- a/apps/desktop-gpui/src/rpc.rs +++ b/apps/desktop-gpui/src/rpc.rs @@ -69,6 +69,15 @@ impl Method for OrchestrationDispatchCommand { type Success = Value; } +/// `server.updateSettings`, a unary call that carries a settings patch. +pub struct ServerUpdateSettings; + +impl Method for ServerUpdateSettings { + const TAG: &'static str = "server.updateSettings"; + type Payload = Value; + type Success = Value; +} + /// `server.getConfig`, a unary call. The wire types are not generated; `providers::Catalog` decodes it. pub struct ServerGetConfig; diff --git a/apps/desktop-gpui/src/send.rs b/apps/desktop-gpui/src/send.rs index 7ce7f8f513e6..2cfca2e36937 100644 --- a/apps/desktop-gpui/src/send.rs +++ b/apps/desktop-gpui/src/send.rs @@ -57,11 +57,17 @@ pub fn title_seed(text: &str) -> String { pub struct Model<'a> { pub instance_id: &'a str, pub slug: &'a str, + /// The `{id, value}` option selections, such as the effort and Fast mode. + pub options: &'a [Value], } impl Model<'_> { fn selection(self) -> Value { - json!({"instanceId": self.instance_id, "model": self.slug}) + let mut selection = json!({"instanceId": self.instance_id, "model": self.slug}); + if !self.options.is_empty() { + selection["options"] = Value::Array(self.options.to_vec()); + } + selection } } @@ -266,6 +272,13 @@ pub fn stop_payload(command_id: &str, thread_id: &str) -> Value { json!({"type": "thread.stop", "commandId": command_id, "threadId": thread_id}) } +/// `server.updateSettings` starring or unstarring one model, as the web's `setModelFavorites`. +pub fn favorite_payload(instance_id: &str, slug: &str, favorite: bool) -> Value { + json!({"patch": {"setModelFavorites": [ + {"provider": instance_id, "model": slug, "favorite": favorite} + ]}}) +} + #[cfg(test)] mod tests { use super::*; @@ -282,6 +295,7 @@ mod tests { const MODEL: Model = Model { instance_id: "codex", slug: "gpt-5", + options: &[], }; fn branch(name: &str) -> Branch { @@ -383,6 +397,27 @@ mod tests { assert_matches_contract("orchestration.launchThread", &plan.payload); } + #[test] + fn the_picked_options_ride_in_the_model_selection() { + let options = [ + json!({"id": "reasoningEffort", "value": "high"}), + json!({"id": "fastMode", "value": true}), + ]; + let model = Model { + options: &options, + ..MODEL + }; + let root = Workspace::Root { branch: None }; + let mut draft = composer("go", Ok(root)); + draft.model = Some(model); + let launch = plan(&draft, ids()).unwrap().payload; + assert_eq!(launch["modelSelection"]["options"], json!(options)); + assert_matches_contract("orchestration.launchThread", &launch); + let dispatch = dispatch_payload("c", "t", "m", "go", Some(model), Delivery::Auto); + assert_eq!(dispatch["modelSelection"]["options"], json!(options)); + assert_matches_contract("orchestration.dispatchCommand", &dispatch); + } + #[test] fn a_worktree_draft_names_its_base_ref() { let main = branch("main"); @@ -545,4 +580,14 @@ mod tests { let wide = "\u{e9}".repeat(51); assert_eq!(title_seed(&wide), format!("{}...", "\u{e9}".repeat(50))); } + + #[test] + fn a_favorite_toggle_matches_the_settings_contract() { + let payload = favorite_payload("codex", "gpt-5", true); + assert_matches_contract("server.updateSettings", &payload); + assert_eq!( + payload["patch"]["setModelFavorites"][0], + json!({"provider": "codex", "model": "gpt-5", "favorite": true}) + ); + } } From 3980e044a881e6328c736ee6388acdb9c5bd7c76 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sat, 7 Nov 2026 23:57:35 -0500 Subject: [PATCH 53/82] feat(desktop-gpui): port the composer model picker popover --- apps/desktop-gpui/src/assets.rs | 5 +- apps/desktop-gpui/src/ui.rs | 542 ++++++++++------ apps/desktop-gpui/src/ui/model_popover.rs | 735 ++++++++++++++++++++++ 3 files changed, 1089 insertions(+), 193 deletions(-) create mode 100644 apps/desktop-gpui/src/ui/model_popover.rs diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index a4f930388424..8d46599584e9 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -62,7 +62,10 @@ gpui_kit_assets::icon_assets!( Trash, Square, SquareTerminal, - X + X, + Star, + StarFill, + Zap ] ); diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index b3f89c1dc22c..546be6cbff72 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -5,6 +5,7 @@ use crate::details::{self, VcsStatus}; use crate::details_card::{DetailsCard, OpenDiff, Props}; use crate::history::History; use crate::minimap; +use crate::model_picker; use crate::projects::{self, EnvironmentProjects, MachineKind, NewProject, ProjectEntry}; use crate::providers::{self, Catalog, Mark, Pick, Trigger}; use crate::send::{self, Primary}; @@ -51,6 +52,7 @@ use std::{ const TERMINAL_SIDEBAR_WIDTH: f32 = 144.; /// The drag handle on the terminal drawer's top edge. const TERMINAL_HANDLE_HEIGHT: f32 = 8.; +mod model_popover; mod right_panel; /// The web's `THREAD_SIDEBAR_DEFAULT_WIDTH`. @@ -203,11 +205,12 @@ pub struct MainView { draft_bases: HashMap, branch_picker: Entity, model_open: bool, - /// The provider instance the model picker's sidebar shows. - model_instance: Option, model_search: Entity, /// The model picked in each composer (an open thread or a project's draft), by details key. model_picks: HashMap, + model_ui: model_picker::State, + /// Routes the picker's keys while it is open; they never reach the search input otherwise. + model_keys: Option, selected_thread: Option, transcript: Option, transcript_list: ListState, @@ -285,6 +288,7 @@ struct Sending { /// Send and stop calls, above the branch picker's range. const SEND_TOKEN: u64 = 1 << 41; const STOP_TOKEN: u64 = SEND_TOKEN + 1; +const FAVORITE_TOKEN: u64 = SEND_TOKEN + 2; impl MainView { pub fn new( @@ -331,8 +335,10 @@ impl MainView { preview: None, }); let model_search = cx.new(|cx| InputState::new(window, cx).placeholder("Search models...")); - cx.subscribe(&model_search, |_, _, event: &InputEvent, cx| { + cx.subscribe(&model_search, |this, _, event: &InputEvent, cx| { if matches!(event, InputEvent::Change) { + this.model_ui.highlight = 0; + this.model_ui.focus = model_picker::Focus::List; cx.notify(); } }) @@ -367,9 +373,10 @@ impl MainView { draft_bases: HashMap::new(), branch_picker, model_open: false, - model_instance: None, model_search, model_picks: HashMap::new(), + model_ui: model_picker::State::default(), + model_keys: None, selected_thread: None, transcript: None, // Overdraw keeps a screenful of rows measured beyond each edge. @@ -575,6 +582,7 @@ impl MainView { model: trigger.as_ref().map(|trigger| send::Model { instance_id: &trigger.instance_id, slug: &trigger.slug, + options: &trigger.options, }), picked: pick.is_some(), runtime_mode: self.runtime_mode.unwrap_or(defaults.runtime_mode), @@ -649,6 +657,12 @@ impl MainView { } return; } + if token == FAVORITE_TOKEN { + if let Some(error) = failed { + self.error_toast("Failed to update favorites", error, cx); + } + return; + } let Some(sent) = self.sending.take() else { return; }; @@ -3126,7 +3140,16 @@ impl MainView { .children(workspace_control) .child(div().flex_1()) .children(branch) - .child(self.permissions_popover(runtime_mode, cx)), + .child(permissions_popover( + &self.permissions, + self.permissions_open, + runtime_mode, + PermissionsTrigger { + mode: runtime_mode, + open: false, + }, + cx, + )), ) } @@ -3230,30 +3253,6 @@ impl MainView { .into_any_element() } - /// The web's `ComposerContextPermissions` menu: opens above the chip, aligned to its end. - fn permissions_popover(&self, mode: RuntimeMode, cx: &App) -> impl IntoElement { - let view = self.permissions.read(cx).view.clone(); - let panel = self.permissions.clone(); - let content_panel = panel.clone(); - Popover::new("permissions") - .anchor(Anchor::BottomRight) - .open(self.permissions_open) - .p_0() - .on_open_change(move |open, _, cx| { - panel.update(cx, |panel, _| panel.preview = None); - view.update(cx, |this, cx| { - this.permissions_open = *open; - cx.notify(); - }) - .ok(); - }) - .trigger(PermissionsTrigger { mode, open: false }) - .content(move |_, _, cx| { - content_panel.update(cx, |panel, _| panel.selected = mode); - content_panel.clone() - }) - } - fn on_branch_event(&mut self, event: &branch_picker::Event, cx: &mut Context) { match event { branch_picker::Event::Base(branch) => { @@ -3289,14 +3288,27 @@ impl MainView { self.model_picks.get(key?) } - fn pick_model(&mut self, instance_id: &str, slug: &str, cx: &mut Context) { + pub(crate) fn pick_model(&mut self, instance_id: &str, slug: &str, cx: &mut Context) { let project = self.selected_project.clone().filter(|id| !id.is_empty()); + self.model_ui.origin = Some(if self.model_ui.tab == Some(model_picker::Tab::Favorites) { + model_picker::Origin::Favorites + } else { + model_picker::Origin::Instance + }); + self.model_open = false; + let key = self.details_key(); + let kept = key + .as_deref() + .and_then(|key| self.model_pick(Some(key))) + .is_some_and(|pick| pick.instance_id == instance_id && pick.model == slug); + if kept { + return cx.notify(); + } let picked = self .providers .as_ref() .and_then(|catalog| catalog.pick(project.as_deref(), instance_id, slug)); - self.model_open = false; - match (picked, self.details_key()) { + match (picked, key) { (Some(pick), Some(key)) => { self.model_picks.insert(key, pick); } @@ -3310,6 +3322,222 @@ impl MainView { cx.notify(); } + /// The picker's instances and the trigger of the model in view. + fn picker_context(&self) -> Option<(Vec, Trigger)> { + let catalog = self.providers.as_ref()?; + let project = self.selected_project.clone().filter(|id| !id.is_empty()); + let key = self.details_key(); + let trigger = catalog.trigger_for(project.as_deref(), self.model_pick(key.as_deref()))?; + Some((catalog.picker(project.as_deref()), trigger)) + } + + /// Opens on the active model's rail entry, or on Favorites (`initialModelPickerProvider`). + fn picker_opened(&mut self, window: &mut Window, cx: &mut Context) { + let Some((instances, trigger)) = self.picker_context() else { + return; + }; + let visible = instances + .iter() + .any(|i| i.instance_id == trigger.instance_id && i.unavailable.is_none()); + let has_favorites = self.providers.as_ref().is_some_and(Catalog::has_favorites); + let tab = model_picker::initial_tab( + &trigger.instance_id, + visible, + has_favorites, + self.model_ui.origin, + ); + let expanded = + model_picker::starts_expanded(&instances, &trigger.instance_id, &trigger.slug); + let listing = model_picker::listing(&instances, &tab, "", expanded); + let highlight = listing + .rows + .iter() + .position(|row| { + row.instance.instance_id == trigger.instance_id && row.model.slug == trigger.slug + }) + .unwrap_or(0); + self.model_ui = model_picker::State { + tab: Some(tab), + expanded, + highlight, + origin: self.model_ui.origin, + ..model_picker::State::default() + }; + let view = cx.weak_entity(); + self.model_keys = Some(cx.intercept_keystrokes(move |event, window, cx| { + let handled = view + .update(cx, |this, cx| this.picker_key(&event.keystroke, window, cx)) + .unwrap_or(false); + if handled { + cx.stop_propagation(); + } + })); + window.focus(&self.model_search.focus_handle(cx), cx); + cx.notify(); + } + + fn picker_closed(&mut self) { + self.model_keys = None; + } + + pub(crate) fn picker_select_tab( + &mut self, + tab: model_picker::Tab, + window: &mut Window, + cx: &mut Context, + ) { + self.model_search + .update(cx, |state, cx| state.set_value("", window, cx)); + self.model_ui.tab = Some(tab); + self.model_ui.expanded = false; + self.model_ui.highlight = 0; + self.model_ui.focus = model_picker::Focus::List; + cx.notify(); + } + + pub(crate) fn picker_toggle_older(&mut self, cx: &mut Context) { + self.model_ui.expanded = !self.model_ui.expanded; + cx.notify(); + } + + /// Writes one option of the model in view into the composer's pick, so the next turn sends it. + pub(crate) fn picker_set_option( + &mut self, + id: &str, + value: serde_json::Value, + cx: &mut Context, + ) { + let (Some((_, trigger)), Some(key)) = (self.picker_context(), self.details_key()) else { + return; + }; + let options = providers::with_option(&trigger.options, id, value); + self.model_picks.insert( + key, + Pick { + instance_id: trigger.instance_id, + model: trigger.slug, + options, + }, + ); + cx.notify(); + } + + /// Stars or unstars a model: the local copy now, the server's `setModelFavorites` behind it. + pub(crate) fn picker_toggle_favorite( + &mut self, + instance_id: &str, + slug: &str, + cx: &mut Context, + ) { + let Some(catalog) = self.providers.as_mut() else { + return; + }; + let favorite = !catalog.is_favorite(instance_id, slug); + catalog.set_favorite(instance_id, slug, favorite); + let action = Action::Call { + token: FAVORITE_TOKEN, + call: crate::connection::Call::UpdateSettings, + payload: send::favorite_payload(instance_id, slug, favorite), + }; + if self.actions.try_send(action).is_err() { + self.error_toast( + "Failed to update favorites", + "The connection is not ready.".to_owned(), + cx, + ); + } + cx.notify(); + } + + /// The picker's keyboard: Cmd+1..9 jump, arrows move within the focused column, Left and Right + /// cross columns at the search's edges. Returns whether the key was taken. + fn picker_key( + &mut self, + keystroke: &Keystroke, + window: &mut Window, + cx: &mut Context, + ) -> bool { + use model_picker::Focus; + if !self.model_open || self.permissions_open { + return false; + } + let Some((instances, trigger)) = self.picker_context() else { + return false; + }; + let modifiers = keystroke.modifiers; + let tab = self + .model_ui + .tab + .clone() + .unwrap_or_else(|| model_picker::Tab::Instance(trigger.instance_id.clone())); + let search = self.model_search.read(cx); + let (query, cursor) = (search.value(), search.cursor()); + let listing = model_picker::listing(&instances, &tab, &query, self.model_ui.expanded); + let key = keystroke.key.as_str(); + if modifiers.platform && !modifiers.alt && !modifiers.control { + let Some(number) = key.parse::().ok().filter(|n| (1..=9).contains(n)) else { + return false; + }; + if let Some(&at) = listing.jump_rows().get(number - 1) { + let row = listing.rows[at]; + let (instance, slug) = (row.instance.instance_id.clone(), row.model.slug.clone()); + self.pick_model(&instance, &slug, cx); + } + return true; + } + if modifiers.platform || modifiers.control || modifiers.alt || modifiers.shift { + return false; + } + let delta = match key { + "down" => 1, + "up" => -1, + _ => 0, + }; + let actions = trigger.controls.actions(); + match (self.model_ui.focus, key) { + (Focus::List, "down" | "up") => { + let at = model_picker::step(self.model_ui.highlight, listing.items(), delta); + self.model_ui.highlight = at; + } + (Focus::List, "enter") => { + let at = self.model_ui.highlight; + if let Some(row) = listing.rows.get(at) { + if row.usable() { + let (instance, slug) = + (row.instance.instance_id.clone(), row.model.slug.clone()); + self.pick_model(&instance, &slug, cx); + } + } else if listing.older.is_some() { + self.model_ui.expanded = !self.model_ui.expanded; + } + } + (Focus::List, "left") if cursor == 0 => self.model_ui.focus = Focus::Sidebar, + (Focus::List, "right") if cursor >= query.len() && !actions.is_empty() => { + self.model_ui.focus = Focus::Effort; + self.model_ui.effort = 0; + } + (Focus::Sidebar, "down" | "up") => { + let next = model_picker::adjacent_tab(&instances, &tab, delta); + self.picker_select_tab(next, window, cx); + self.model_ui.focus = Focus::Sidebar; + } + (Focus::Sidebar, "right" | "enter") => self.model_ui.focus = Focus::List, + (Focus::Effort, "down" | "up") => { + self.model_ui.effort = + model_picker::step(self.model_ui.effort, actions.len(), delta); + } + (Focus::Effort, "enter") => { + if let Some((id, value)) = actions.get(self.model_ui.effort) { + self.picker_set_option(id, value.clone(), cx); + } + } + (Focus::Effort, "left") => self.model_ui.focus = Focus::List, + _ => return false, + } + cx.notify(); + true + } + fn open_permissions(&mut self, cx: &mut Context) { let strip = self.shell().is_some_and(|shell| { active_project(self.selected_project.as_deref(), shell).is_some_and(|e| !e.scratch) @@ -3318,178 +3546,59 @@ impl MainView { cx.notify(); } - /// The web's `ProviderModelPicker`: instances in a sidebar, a search, and the model list. + /// The web's `ProviderModelPicker`: the chip, and the popover opening above it. fn model_popover(&self, cx: &mut Context) -> Option { - let catalog = self.providers.as_ref()?; + let (instances, trigger) = self.picker_context()?; let project = self.selected_project.clone().filter(|id| !id.is_empty()); - let key = self.details_key(); - let trigger = catalog.trigger_for(project.as_deref(), self.model_pick(key.as_deref()))?; - let instances = catalog.picker(project.as_deref()); + let mode = self.runtime_mode.unwrap_or_else(|| { + self.providers + .as_ref() + .map_or(RuntimeMode::FullAccess, |catalog| { + catalog.defaults(project.as_deref()).runtime_mode + }) + }); let (view, search) = (cx.entity(), self.model_search.clone()); - let shown = self - .model_instance - .clone() - .unwrap_or_else(|| trigger.instance_id.clone()); - let current = (trigger.instance_id.clone(), trigger.slug.clone()); + let panel = model_popover::Panel { + view: view.clone(), + search: search.clone(), + tab: self + .model_ui + .tab + .clone() + .unwrap_or_else(|| model_picker::Tab::Instance(trigger.instance_id.clone())), + expanded: self.model_ui.expanded, + highlight: self.model_ui.highlight, + focus: self.model_ui.focus, + effort: self.model_ui.effort, + permissions: self.permissions.clone(), + permissions_open: self.permissions_open, + mode, + instances, + trigger: trigger.clone(), + }; Some( Popover::new("model-picker") .anchor(Anchor::BottomRight) .open(self.model_open) .track_focus(&search.focus_handle(cx)) .p_0() - .on_open_change({ - let (view, search) = (view.clone(), search.clone()); - move |open, window, cx| { - search.update(cx, |state, cx| state.set_value("", window, cx)); - view.update(cx, |this, cx| { - this.model_open = *open; - this.model_instance = None; - cx.notify(); - }); - } + .on_open_change(move |open, window, cx| { + search.update(cx, |state, cx| state.set_value("", window, cx)); + view.update(cx, |this, cx| { + this.model_open = *open; + if *open { + this.picker_opened(window, cx); + } else { + this.picker_closed(); + } + cx.notify(); + }); }) .trigger(ModelTrigger { trigger, open: self.model_open, }) - .content(move |_, _, cx| { - let query = search.read(cx).value(); - let theme = cx.theme(); - let (muted, hover, selected) = - (theme.muted_foreground, theme.list_hover, theme.list_active); - let (border, foreground) = (theme.border, theme.foreground); - let searching = !query.trim().is_empty(); - let listed: Vec<_> = if searching { - providers::rank_models(&instances, &query) - } else { - instances - .iter() - .filter(|instance| instance.instance_id == shown) - .flat_map(|instance| instance.models.iter().map(move |m| (instance, m))) - .collect() - }; - let sidebar = instances.iter().enumerate().map(|(index, instance)| { - let (view, id) = (view.clone(), instance.instance_id.clone()); - let usable = instance.unavailable.is_none(); - let active = !searching && instance.instance_id == shown; - let note = instance.unavailable.clone(); - div() - .id(("model-instance", index)) - .flex() - .items_center() - .justify_center() - .size(px(32.)) - .rounded_md() - .when(active, |item| item.bg(selected)) - .when(usable, |item| { - item.cursor_pointer().hover(move |style| style.bg(hover)) - }) - .when(!usable, |item| item.opacity(0.45)) - .tooltip(tooltip(note.unwrap_or_else(|| instance.name.clone()))) - .child(provider_mark(&instance.mark, 1., cx)) - .when(usable, |item| { - item.on_click(move |_, _, cx| { - view.update(cx, |this, cx| { - this.model_instance = Some(id.clone()); - cx.notify(); - }); - }) - }) - }); - let rows = listed.iter().enumerate().map(|(index, (instance, model))| { - let (view, id, slug) = ( - view.clone(), - instance.instance_id.clone(), - model.slug.clone(), - ); - let chosen = (id.as_str(), slug.as_str()) - == (current.0.as_str(), current.1.as_str()); - let usable = instance.unavailable.is_none(); - h_flex() - .id(("model-row", index)) - .gap_2() - .px_2() - .py_1p5() - .items_center() - .rounded_md() - .text_sm() - .when(usable, |row| { - row.cursor_pointer().hover(move |style| style.bg(hover)) - }) - .when(!usable, |row| row.opacity(0.45)) - .tooltip(tooltip(model.title.clone())) - .when(searching, |row| { - row.child(provider_mark(&instance.mark, 1., cx)) - }) - .child( - div() - .flex_1() - .min_w_0() - .truncate() - .child(model.label.clone()), - ) - .when(chosen, |row| { - row.child(Icon::new(IconName::Check).small().text_color(foreground)) - }) - .when(usable, |row| { - row.on_click(move |_, _, cx| { - view.update(cx, |this, cx| this.pick_model(&id, &slug, cx)); - }) - }) - }); - h_flex() - .w(px(448.)) - .h(px(320.)) - .child( - v_flex() - .gap_1() - .p_1() - .border_r_1() - .border_color(border.opacity(0.7)) - .children(sidebar), - ) - .child( - v_flex() - .flex_1() - .min_w_0() - .child( - div().px_3().pt(px(10.)).child( - h_flex() - .pb(px(6.)) - .border_b_1() - .border_color(border.opacity(0.7)) - .child( - Input::new(&search) - .appearance(false) - .h(px(26.)) - .px_0() - .prefix( - Icon::new(IconName::Search) - .size_4() - .text_color(muted.opacity(0.55)), - ), - ), - ), - ) - .child( - v_flex() - .id("model-list") - .flex_1() - .overflow_y_scroll() - .p_1() - .children(rows) - .when(listed.is_empty(), |list| { - list.child( - div() - .p_2() - .text_sm() - .text_color(muted) - .child("No matching models."), - ) - }), - ), - ) - }) + .content(move |_, window, cx| panel.render(window, cx)) .into_any_element(), ) } @@ -3632,6 +3741,35 @@ impl RenderOnce for StripTrigger { } } +/// The web's `ComposerContextPermissions` menu: opens above its trigger, aligned to the end. +fn permissions_popover( + panel: &Entity, + open: bool, + mode: RuntimeMode, + trigger: impl Selectable + IntoElement + 'static, + cx: &App, +) -> impl IntoElement { + let view = panel.read(cx).view.clone(); + let (panel, content_panel) = (panel.clone(), panel.clone()); + Popover::new("permissions") + .anchor(Anchor::BottomRight) + .open(open) + .p_0() + .on_open_change(move |open, _, cx| { + panel.update(cx, |panel, _| panel.preview = None); + view.update(cx, |this, cx| { + this.permissions_open = *open; + cx.notify(); + }) + .ok(); + }) + .trigger(trigger) + .content(move |_, _, cx| { + content_panel.update(cx, |panel, _| panel.selected = mode); + content_panel.clone() + }) +} + /// The permissions chip as a popover trigger. #[derive(IntoElement)] struct PermissionsTrigger { @@ -3868,6 +4006,12 @@ fn model_trigger(trigger: &Trigger, open: bool, cx: &App) -> gpui::Stateful gpui::Stateful gpui::Div { + provider_mark_at(mark, 16., 16., icon_opacity, cx) +} + +/// The mark in a `frame` square with its art at `art`: the picker's rail uses `size-6` around `size-5`. +fn provider_mark_at( + mark: &Mark, + frame: f32, + art_size: f32, + icon_opacity: f32, + cx: &App, +) -> gpui::Div { let theme = cx.theme(); let art = match provider_icon(&mark.driver, theme.is_dark()) { Some(path) => img(path).size_full().into_any_element(), @@ -3899,9 +4054,12 @@ fn provider_mark(mark: &Mark, icon_opacity: f32, cx: &App) -> gpui::Div { }; div() .relative() + .flex() .flex_shrink_0() - .size_4() - .child(div().size_full().opacity(icon_opacity).child(art)) + .items_center() + .justify_center() + .size(px(frame)) + .child(div().size(px(art_size)).opacity(icon_opacity).child(art)) .children(mark.badge.as_ref().map(|badge| { let (bg, fg) = match badge.accent { Some((bg, fg)) => (rgb(bg).into(), rgb(fg).into()), diff --git a/apps/desktop-gpui/src/ui/model_popover.rs b/apps/desktop-gpui/src/ui/model_popover.rs new file mode 100644 index 000000000000..067da5e1654e --- /dev/null +++ b/apps/desktop-gpui/src/ui/model_popover.rs @@ -0,0 +1,735 @@ +//! The web's `ProviderModelPicker` popover: the rail and model list of `ModelPickerContent.tsx`, +//! the effort column of `ComposerModelEffort.tsx`, and the footer's speed and permissions controls. + +use super::*; +use crate::model_picker::{Focus, Listing, Tab}; +use crate::providers::{Control, PickerInstance}; + +/// `h-86.5`: the body, rail and columns, above the footer. +const BODY_HEIGHT: f32 = 346.; +/// `w-screen max-w-90`: the rail and model list together. +const PICKER_WIDTH: f32 = 360.; +/// The effort column's `w-48`. +const EFFORT_WIDTH: f32 = 192.; +/// The rail's `w-11`. +const RAIL_WIDTH: f32 = 44.; +/// `COLUMN_ROW`'s `h-8`. +const ROW_HEIGHT: f32 = 32.; + +pub(super) struct Panel { + pub view: Entity, + pub search: Entity, + pub instances: Vec, + pub trigger: Trigger, + pub tab: Tab, + pub expanded: bool, + pub highlight: usize, + pub focus: Focus, + pub effort: usize, + pub permissions: Entity, + pub permissions_open: bool, + pub mode: RuntimeMode, +} + +/// The tokens every part of the popover paints with. +#[derive(Clone, Copy)] +struct Palette { + foreground: Hsla, + muted: Hsla, + border: Hsla, + primary: Hsla, + warning: Hsla, + fill: Hsla, + hover: Hsla, + track: Hsla, + selected: Hsla, +} + +impl Palette { + fn of(cx: &App) -> Self { + let theme = cx.theme(); + Self { + foreground: theme.foreground, + muted: theme.muted_foreground, + border: theme.border.opacity(0.7), + primary: theme.primary, + warning: theme.warning, + fill: theme.muted.opacity(0.4), + hover: theme.foreground.opacity(0.09), + track: theme.input.opacity(0.4), + selected: theme.input.opacity(0.72), + } + } +} + +impl Panel { + pub fn render(&self, window: &mut Window, cx: &mut App) -> AnyElement { + let palette = Palette::of(cx); + let query = self.search.read(cx).value(); + let listing = model_picker::listing(&self.instances, &self.tab, &query, self.expanded); + let searching = !query.trim().is_empty(); + let controls = &self.trigger.controls; + let effort_column = controls.effort.is_some() || !controls.others.is_empty(); + let width = PICKER_WIDTH + if effort_column { EFFORT_WIDTH } else { 0. }; + let search_focused = self.search.focus_handle(cx).is_focused(window); + v_flex() + .w(px(width)) + .child( + h_flex() + .h(px(BODY_HEIGHT)) + .child(self.rail(searching, palette, cx)) + .child(self.models(&listing, searching, search_focused, palette, cx)) + .children(effort_column.then(|| self.effort_column(palette))), + ) + .child(self.footer(palette, cx)) + .into_any_element() + } + + /// `ModelPickerSidebar`: the favorites star, a divider, then one square per instance. + fn rail(&self, searching: bool, palette: Palette, cx: &App) -> impl IntoElement { + let has_favorites = self + .instances + .iter() + .any(|i| i.models.iter().any(|m| m.favorite)); + let favorites = self.rail_item( + "model-tab-favorites", + searching, + self.tab == Tab::Favorites, + "Favorites".to_owned(), + true, + Tab::Favorites, + palette, + Icon::new(if has_favorites { + Icons::StarFill + } else { + Icons::Star + }) + .size(px(20.)) + .text_color(if self.tab == Tab::Favorites && !searching { + palette.foreground + } else { + palette.muted + }) + .into_any_element(), + ); + let items = self.instances.iter().enumerate().map(|(index, instance)| { + let tab = Tab::Instance(instance.instance_id.clone()); + let note = instance + .unavailable + .clone() + .unwrap_or_else(|| instance.name.clone()); + self.rail_item( + ("model-tab", index), + searching, + self.tab == tab, + note, + instance.unavailable.is_none(), + tab, + palette, + provider_mark_at(&instance.mark, 24., 20., 1., cx).into_any_element(), + ) + }); + v_flex() + .id("model-rail") + .w(px(RAIL_WIDTH)) + .h_full() + .flex_shrink_0() + .gap_1() + .p_1() + .bg(cx.theme().muted.opacity(0.3)) + .overflow_y_scroll() + .child(favorites) + .child(div().h_px().w_full().flex_shrink_0().bg(palette.border)) + .children(items) + } + + #[allow(clippy::too_many_arguments)] + fn rail_item( + &self, + id: impl Into, + searching: bool, + active: bool, + note: String, + usable: bool, + tab: Tab, + palette: Palette, + icon: AnyElement, + ) -> impl IntoElement { + let view = self.view.clone(); + let current = active && !searching; + let focused = current && self.focus == Focus::Sidebar; + div() + .id(id) + .relative() + .flex() + .flex_shrink_0() + .items_center() + .justify_center() + .size(px(RAIL_WIDTH - 8.)) + .rounded_md() + .when(focused, |item| item.bg(palette.hover)) + .when(usable, |item| { + item.cursor_pointer() + .hover(move |style| style.bg(palette.foreground.opacity(0.1))) + }) + .when(!usable, |item| item.opacity(0.45)) + .tooltip(tooltip(note)) + .child(icon) + .when(current, |item| { + // `h-5 w-0.75` at `right-1`, centered on the item. + item.child( + div() + .absolute() + .right(px(4.)) + .top(px((RAIL_WIDTH - 8. - 20.) / 2.)) + .h(px(20.)) + .w(px(3.)) + .rounded_full() + .bg(palette.primary), + ) + }) + .when(usable, |item| { + item.on_click(move |_, window, cx| { + view.update(cx, |this, cx| { + this.picker_select_tab(tab.clone(), window, cx) + }); + }) + }) + } + + /// The middle column: the search, the rows, and the older-models toggle. + fn models( + &self, + listing: &Listing, + searching: bool, + search_focused: bool, + palette: Palette, + cx: &App, + ) -> impl IntoElement { + let jumps = listing.jump_rows(); + let on_list = self.focus == Focus::List; + let rows = listing.rows.iter().enumerate().map(|(index, row)| { + let jump = jumps.iter().position(|&at| at == index); + self.model_row( + index, + row.instance, + row.model, + jump, + on_list && self.highlight == index, + searching || self.tab == Tab::Favorites, + palette, + cx, + ) + }); + let toggle = listing.older.map(|expanded| { + let view = self.view.clone(); + let highlighted = on_list && self.highlight == listing.rows.len(); + div() + .id("model-older") + .flex() + .h(px(ROW_HEIGHT)) + .w_full() + .flex_shrink_0() + .items_center() + .px_2() + .rounded_sm() + .text_sm() + .text_color(palette.muted) + .cursor_pointer() + .when(highlighted, |row| { + row.bg(palette.hover).text_color(palette.foreground) + }) + .hover(move |style| style.text_color(palette.foreground)) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| this.picker_toggle_older(cx)); + }) + .child(if expanded { "Show less" } else { "Show more" }) + }); + let empty = listing.rows.is_empty().then(|| { + let text = if searching { + "No models found" + } else if self.tab == Tab::Favorites { + "No favorites yet" + } else { + "No models" + }; + div() + .px_2() + .py_3() + .text_sm() + .text_color(palette.muted) + .child(text) + }); + v_flex() + .flex_1() + .min_w_0() + .h_full() + .bg(palette.fill) + .border_l_1() + .border_color(palette.border) + .child( + // The search sits in a `px-3 pt-2.5` strip, underlined in the primary tone on focus. + div().px_3().pt(px(10.)).child( + h_flex() + .pb(px(6.)) + .border_b_1() + .border_color(if search_focused { + palette.primary + } else { + palette.border + }) + .child( + Input::new(&self.search) + .appearance(false) + .h(px(26.)) + .px_0() + .prefix( + Icon::new(IconName::Search) + .size_4() + .text_color(palette.muted.opacity(0.55)), + ), + ), + ), + ) + .child( + v_flex() + .id("model-list") + .flex_1() + .min_h_0() + .gap(px(2.)) + .overflow_y_scroll() + .py(px(6.)) + .pl_2() + .pr_px() + .children(rows) + .children(toggle) + .children(empty), + ) + } + + #[allow(clippy::too_many_arguments)] + fn model_row( + &self, + index: usize, + instance: &PickerInstance, + model: &providers::PickerModel, + jump: Option, + highlighted: bool, + show_provider: bool, + palette: Palette, + cx: &App, + ) -> impl IntoElement { + let usable = instance.unavailable.is_none(); + let chosen = + instance.instance_id == self.trigger.instance_id && model.slug == self.trigger.slug; + let group: SharedString = format!("model-row-{index}").into(); + let (view, id, slug) = ( + self.view.clone(), + instance.instance_id.clone(), + model.slug.clone(), + ); + let star = { + let (view, id, slug) = (view.clone(), id.clone(), slug.clone()); + let favorite = model.favorite; + // A starred model shows its star, except in the favorites tab where it is hover-only. + let resting = favorite && self.tab != Tab::Favorites; + let tip = if favorite { + "Remove from favorites" + } else { + "Add to favorites" + }; + div() + .id(("model-star", index)) + .flex() + .size(px(24.)) + .flex_shrink_0() + .items_center() + .justify_center() + .rounded_md() + .cursor_pointer() + .text_color(palette.muted) + .when(!resting && !highlighted, |star| { + star.opacity(0.) + .group_hover(group.clone(), |style| style.opacity(1.)) + }) + .hover(move |style| style.bg(palette.hover).text_color(palette.foreground)) + .tooltip(tooltip(tip)) + .on_click(move |_, _, cx| { + cx.stop_propagation(); + view.update(cx, |this, cx| this.picker_toggle_favorite(&id, &slug, cx)); + }) + .child( + Icon::new(if favorite { + Icons::StarFill + } else { + Icons::Star + }) + .size(px(12.)) + .text_color(if favorite { + palette.warning + } else { + palette.muted + }), + ) + }; + h_flex() + .id(("model-row", index)) + .group(group) + .h(px(ROW_HEIGHT)) + .w_full() + .flex_shrink_0() + .gap(px(6.)) + .px_2() + .items_center() + .rounded_sm() + .text_sm() + .when(highlighted, |row| row.bg(palette.hover)) + .when(usable, |row| { + row.cursor_pointer() + .hover(move |style| style.bg(palette.hover)) + }) + .when(!usable, |row| row.opacity(0.45)) + .tooltip(tooltip(model.title.clone())) + .when(show_provider, |row| { + row.child(provider_mark(&instance.mark, 1., cx)) + }) + .child( + div() + .min_w_0() + .truncate() + .line_height(px(22.)) + .child(model.label.clone()), + ) + .children(model.sub_provider.clone().map(|name| { + div() + .flex_shrink_0() + .text_xs() + .text_color(palette.muted.opacity(0.7)) + .child(name) + })) + .children(model.new.then(|| { + div() + .flex_shrink_0() + .rounded_sm() + .px_1() + .text_xs() + .text_color(palette.primary) + .bg(palette.primary.opacity(0.12)) + .child("New") + })) + .child(div().flex_1()) + .when(chosen, |row| { + row.child( + Icon::new(IconName::Check) + .size(px(14.)) + .text_color(palette.foreground), + ) + }) + .when(usable, |row| row.child(star)) + .children(jump.map(|n| { + div() + .flex_shrink_0() + .px(px(4.)) + .rounded_sm() + .border_1() + .border_color(palette.border) + .text_size(px(10.)) + .line_height(px(14.)) + .text_color(palette.muted) + .child(format!("\u{2318}{}", n + 1)) + })) + .when(usable, |row| { + row.on_click(move |_, _, cx| { + view.update(cx, |this, cx| this.pick_model(&id, &slug, cx)); + }) + }) + } + + /// `ComposerModelEffort`: the header, then a row per level and per further option. + fn effort_column(&self, palette: Palette) -> impl IntoElement { + let controls = &self.trigger.controls; + let on_column = self.focus == Focus::Effort; + let mut index = 0; + let mut rows = Vec::new(); + if let Some(effort) = &controls.effort { + for level in &effort.levels { + rows.push( + self.level_row( + index, + effort, + level, + on_column && self.effort == index, + palette, + ) + .into_any_element(), + ); + index += 1; + } + if effort.levels.is_empty() { + rows.push( + div() + .px_2() + .py_1() + .text_xs() + .text_color(palette.muted) + .child("No effort levels") + .into_any_element(), + ); + } + } + for control in &controls.others { + rows.push( + self.option_row(index, control, on_column && self.effort == index, palette) + .into_any_element(), + ); + index += 1; + } + v_flex() + .w(px(EFFORT_WIDTH)) + .h_full() + .flex_shrink_0() + .border_l_1() + .border_color(palette.border) + .child( + div().px_3().pt(px(10.)).child( + h_flex() + .h(px(33.)) + .mt(px(-1.)) + .gap_2() + .pb(px(6.)) + .items_center() + .border_b_1() + .border_color(palette.border) + .text_sm() + .child(div().text_color(palette.muted).child(controls.title())) + .child( + div() + .ml_auto() + .min_w_0() + .truncate() + .text_xs() + .text_color(palette.muted) + .child(self.trigger.model.clone()), + ), + ), + ) + .child( + v_flex() + .id("model-effort-list") + .flex_1() + .min_h_0() + .gap(px(2.)) + .overflow_y_scroll() + .px_2() + .pt(px(10.)) + .pb(px(6.)) + .children(rows), + ) + } + + fn level_row( + &self, + index: usize, + control: &Control, + level: &providers::Level, + highlighted: bool, + palette: Palette, + ) -> impl IntoElement { + let (view, id, value) = (self.view.clone(), control.id.clone(), level.value.clone()); + h_flex() + .id(("model-level", index)) + .h(px(ROW_HEIGHT)) + .w_full() + .flex_shrink_0() + .gap_2() + .px_2() + .items_center() + .rounded_sm() + .text_sm() + .cursor_pointer() + .when(highlighted, |row| row.bg(palette.hover)) + .hover(move |style| style.bg(palette.hover)) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| { + this.picker_set_option(&id, value.clone(), cx) + }); + }) + .child(div().min_w_0().truncate().child(level.label.clone())) + .when(level.is_default && !level.selected, |row| { + row.child( + div() + .text_xs() + .text_color(palette.muted.opacity(0.7)) + .child("Default"), + ) + }) + .child(div().flex_1()) + .when(level.selected, |row| { + row.child( + Icon::new(IconName::Check) + .size(px(14.)) + .text_color(palette.foreground), + ) + }) + } + + /// A further option as one row: its name over the current value, clicking steps to the next. + fn option_row( + &self, + index: usize, + control: &Control, + highlighted: bool, + palette: Palette, + ) -> impl IntoElement { + let (view, id) = (self.view.clone(), control.id.clone()); + let next = control.next().map(|level| level.value.clone()); + v_flex() + .id(("model-option", index)) + .w_full() + .flex_shrink_0() + .py_1() + .px_2() + .rounded_sm() + .cursor_pointer() + .when(highlighted, |row| row.bg(palette.hover)) + .hover(move |style| style.bg(palette.hover)) + .on_click(move |_, _, cx| { + if let Some(value) = next.clone() { + view.update(cx, |this, cx| this.picker_set_option(&id, value, cx)); + } + }) + .child( + div() + .text_xs() + .text_color(palette.muted) + .child(control.label.clone()), + ) + .child( + div().text_sm().truncate().child( + control + .current() + .map(|level| level.label.clone()) + .unwrap_or_default(), + ), + ) + } + + /// The footer: the speed segments, and the permissions control at the end. + fn footer(&self, palette: Palette, cx: &App) -> impl IntoElement { + let speed = self.trigger.controls.speed.as_ref().map(|speed| { + let last = speed.levels.len().saturating_sub(1); + let segments = speed.levels.iter().enumerate().map(|(index, level)| { + let (view, id, value) = (self.view.clone(), speed.id.clone(), level.value.clone()); + let selected = level.selected; + let bolt = providers::is_fast_label(&level.label); + h_flex() + .id(("model-speed", index)) + .h(px(24.)) + .px_2() + .gap_1() + .items_center() + .text_xs() + .text_color(palette.muted) + .cursor_pointer() + .when(index == 0, |s| s.rounded_l_md()) + .when(index == last, |s| s.rounded_r_md()) + .when(selected, |s| { + s.bg(palette.selected).text_color(palette.foreground) + }) + .hover(move |style| style.text_color(palette.foreground)) + .when_some(level.description.clone(), |s, tip| s.tooltip(tooltip(tip))) + .when(level.description.is_none() && bolt, |s| { + s.tooltip(tooltip("2x speed, increased usage")) + }) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| { + this.picker_set_option(&id, value.clone(), cx) + }); + }) + .children(bolt.then(|| Icon::new(Icons::Zap).size(px(12.)))) + .child(level.label.clone()) + }); + h_flex() + .rounded_lg() + .bg(palette.track) + .p(px(2.)) + .children(segments) + }); + h_flex() + .flex_wrap() + .items_center() + .gap_2() + .px_3() + .py_2() + .border_t_1() + .border_color(palette.border) + .bg(palette.fill) + .children(speed) + .child(div().ml_auto().child(permissions_popover( + &self.permissions, + self.permissions_open, + self.mode, + FooterPermissions { + mode: self.mode, + open: false, + hover: palette.hover, + foreground: palette.foreground, + muted: palette.muted, + }, + cx, + ))) + } +} + +/// `PermissionsMenu`'s trigger: `h-7 gap-1.5 rounded-md px-2 text-xs`, the mode's icon, its name, +/// and a chevron. +#[derive(IntoElement)] +struct FooterPermissions { + mode: RuntimeMode, + open: bool, + hover: Hsla, + foreground: Hsla, + muted: Hsla, +} + +impl Selectable for FooterPermissions { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } + + fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + fn is_open(&self) -> bool { + self.open + } +} + +impl RenderOnce for FooterPermissions { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let (hover, foreground) = (self.hover, self.foreground); + h_flex() + .id("model-permissions") + .h(px(28.)) + .gap(px(6.)) + .px_2() + .items_center() + .rounded_md() + .text_xs() + .text_color(self.muted) + .cursor_pointer() + .hover(move |style| style.bg(hover).text_color(foreground)) + .when(self.open, move |button| { + button.bg(hover).text_color(foreground) + }) + .child(Icon::new(runtime_icon(self.mode)).size(px(14.))) + .child(self.mode.label()) + .child(Icon::new(Icons::ChevronUp).size(px(12.))) + } +} From 951fe939ec9dbcb32f7fbbe43226a7dfbc46aa7f Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 8 Nov 2026 00:19:47 -0500 Subject: [PATCH 54/82] fix(desktop-gpui): model picker hover states and tooltips match the web Rows no longer show the model name as a tooltip, the speed segments no longer invent a description, effort levels show their description, the favorite star hovers with the accent fill, and unavailable rail items dim to 50% and read like describeUnavailableInstance (Disabled in settings, Unavailable, Limited, Not ready, plus the provider message). --- apps/desktop-gpui/src/providers.rs | 49 ++++++++++++++++++++--- apps/desktop-gpui/src/ui/model_popover.rs | 15 +++---- 2 files changed, 51 insertions(+), 13 deletions(-) diff --git a/apps/desktop-gpui/src/providers.rs b/apps/desktop-gpui/src/providers.rs index 2d81aed22465..c6ec3d4138b1 100644 --- a/apps/desktop-gpui/src/providers.rs +++ b/apps/desktop-gpui/src/providers.rs @@ -159,6 +159,8 @@ struct Provider { status: String, #[serde(default)] availability: Option, + #[serde(default)] + message: Option, #[serde(default, deserialize_with = "lenient")] models: Vec, /// Kept raw: `usage::Limits` decodes it for the details card. @@ -475,7 +477,7 @@ impl Catalog { PickerInstance { instance_id: provider.instance_id.clone(), name: mark.display_name.clone(), - unavailable: (!entry.available() || provider.status == "error") + unavailable: (!entry.available() || provider.status != "ready") .then(|| unavailable_note(&mark.display_name, provider)), models: provider .models @@ -673,12 +675,18 @@ impl Catalog { /// `describeUnavailableInstance` for an instance the picker cannot select. fn unavailable_note(name: &str, provider: &Provider) -> String { - let kind = if provider.status == "error" { - "Unavailable" - } else { - "Not ready" + if provider.status == "disabled" { + return format!("{name}: Disabled in settings."); + } + let kind = match provider.status.as_str() { + "error" => "Unavailable", + "warning" => "Limited", + _ => "Not ready", }; - format!("{name}: {kind}.") + match provider.message.as_deref().map(str::trim) { + Some(message) if !message.is_empty() => format!("{name}: {kind}. {message}"), + _ => format!("{name}: {kind}."), + } } fn config_enabled(config: &InstanceConfig, driver: &str) -> bool { @@ -1447,6 +1455,7 @@ mod tests { accent_color: None, status: "ready".into(), availability: None, + message: None, models: Vec::new(), usage_limits: None, }; @@ -1459,6 +1468,34 @@ mod tests { assert_eq!(humanize("claudeAgent"), "Claude Agent"); } + #[test] + fn unavailable_notes_follow_describe_unavailable_instance() { + let note = |status: &str, message: Option<&str>| { + let provider = Provider { + instance_id: "claudeAgent".into(), + driver: "claudeAgent".into(), + display_name: None, + accent_color: None, + status: status.into(), + availability: None, + message: message.map(str::to_owned), + models: Vec::new(), + usage_limits: None, + }; + unavailable_note("Claude", &provider) + }; + assert_eq!( + note("disabled", Some("ignored")), + "Claude: Disabled in settings." + ); + assert_eq!(note("error", None), "Claude: Unavailable."); + assert_eq!( + note("warning", Some(" Rate limited ")), + "Claude: Limited. Rate limited" + ); + assert_eq!(note("pending", Some(" ")), "Claude: Not ready."); + } + #[test] fn unconfigured_instances_enable_only_codex_and_claude() { let catalog = catalog( diff --git a/apps/desktop-gpui/src/ui/model_popover.rs b/apps/desktop-gpui/src/ui/model_popover.rs index 067da5e1654e..ad94936fb261 100644 --- a/apps/desktop-gpui/src/ui/model_popover.rs +++ b/apps/desktop-gpui/src/ui/model_popover.rs @@ -41,6 +41,7 @@ struct Palette { warning: Hsla, fill: Hsla, hover: Hsla, + accent: Hsla, track: Hsla, selected: Hsla, } @@ -56,6 +57,7 @@ impl Palette { warning: theme.warning, fill: theme.muted.opacity(0.4), hover: theme.foreground.opacity(0.09), + accent: theme.accent, track: theme.input.opacity(0.4), selected: theme.input.opacity(0.72), } @@ -167,12 +169,12 @@ impl Panel { .justify_center() .size(px(RAIL_WIDTH - 8.)) .rounded_md() - .when(focused, |item| item.bg(palette.hover)) + .when(focused, |item| item.bg(palette.foreground.opacity(0.1))) .when(usable, |item| { item.cursor_pointer() .hover(move |style| style.bg(palette.foreground.opacity(0.1))) }) - .when(!usable, |item| item.opacity(0.45)) + .when(!usable, |item| item.opacity(0.5).cursor_not_allowed()) .tooltip(tooltip(note)) .child(icon) .when(current, |item| { @@ -352,7 +354,7 @@ impl Panel { star.opacity(0.) .group_hover(group.clone(), |style| style.opacity(1.)) }) - .hover(move |style| style.bg(palette.hover).text_color(palette.foreground)) + .hover(move |style| style.bg(palette.accent).text_color(palette.foreground)) .tooltip(tooltip(tip)) .on_click(move |_, _, cx| { cx.stop_propagation(); @@ -389,7 +391,6 @@ impl Panel { .hover(move |style| style.bg(palette.hover)) }) .when(!usable, |row| row.opacity(0.45)) - .tooltip(tooltip(model.title.clone())) .when(show_provider, |row| { row.child(provider_mark(&instance.mark, 1., cx)) }) @@ -549,6 +550,9 @@ impl Panel { .cursor_pointer() .when(highlighted, |row| row.bg(palette.hover)) .hover(move |style| style.bg(palette.hover)) + .when_some(level.description.clone(), |row, tip| { + row.tooltip(tooltip(tip)) + }) .on_click(move |_, _, cx| { view.update(cx, |this, cx| { this.picker_set_option(&id, value.clone(), cx) @@ -638,9 +642,6 @@ impl Panel { }) .hover(move |style| style.text_color(palette.foreground)) .when_some(level.description.clone(), |s, tip| s.tooltip(tooltip(tip))) - .when(level.description.is_none() && bolt, |s| { - s.tooltip(tooltip("2x speed, increased usage")) - }) .on_click(move |_, _, cx| { view.update(cx, |this, cx| { this.picker_set_option(&id, value.clone(), cx) From 82429e3ba772bc3732ddd3209d6edad2b1b4beb1 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 8 Nov 2026 01:00:53 -0500 Subject: [PATCH 55/82] feat(desktop-gpui): attach files to the composer The paperclip opened nothing. Files now stage and upload at attach time like the web: attachments.createUploadUrl, a signed POST of the bytes with progress, three uploads at a time, delete on remove, and a send that waits for uploads and carries the attachment context records. --- apps/desktop-gpui/Cargo.lock | 3 + apps/desktop-gpui/Cargo.toml | 3 + apps/desktop-gpui/src/attachments.rs | 798 +++++++++++++++++++++++++++ apps/desktop-gpui/src/connection.rs | 34 +- apps/desktop-gpui/src/main.rs | 2 + apps/desktop-gpui/src/providers.rs | 55 ++ apps/desktop-gpui/src/rpc.rs | 18 + apps/desktop-gpui/src/send.rs | 249 +++++++-- apps/desktop-gpui/src/ui.rs | 496 ++++++++++++++++- apps/desktop-gpui/src/upload.rs | 174 ++++++ 10 files changed, 1769 insertions(+), 63 deletions(-) create mode 100644 apps/desktop-gpui/src/attachments.rs create mode 100644 apps/desktop-gpui/src/upload.rs diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index 4965eec0278a..93798877e7ce 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -6258,6 +6258,7 @@ version = "0.1.0" dependencies = [ "alacritty_terminal", "async-channel", + "bytes", "futures-util", "getrandom 0.4.3", "gpui-component", @@ -6266,6 +6267,8 @@ dependencies = [ "gpui-pre-platform", "hex", "hmac 0.13.0", + "http-body", + "http-body-util", "libc", "markdown", "prettyplease", diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index ef8c98c9af1e..75d2ca22ddd6 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -8,6 +8,7 @@ publish = false [dependencies] alacritty_terminal = "=0.26.0" async-channel = "2.5.0" +bytes = "1" futures-util = "0.3.34" getrandom = "0.4.3" gpui = { package = "gpui-pre", version = "=0.3.11" } @@ -16,6 +17,7 @@ gpui-kit-assets = "=0.9.0" gpui_platform = { package = "gpui-pre-platform", version = "=0.3.11", features = ["font-kit"] } hex = "0.4.3" hmac = "0.13.0" +http-body = "1" libc = "0.2.191" markdown = "1.0.0" reqwest = { version = "0.13.5", default-features = false, features = ["form", "json", "rustls"] } @@ -39,6 +41,7 @@ lto = "thin" strip = true [dev-dependencies] +http-body-util = "0.1" prettyplease = "0.3.0" schemars = "0.8" syn = "3.0.9" diff --git a/apps/desktop-gpui/src/attachments.rs b/apps/desktop-gpui/src/attachments.rs new file mode 100644 index 000000000000..8a93d6e3d172 --- /dev/null +++ b/apps/desktop-gpui/src/attachments.rs @@ -0,0 +1,798 @@ +//! The composer's file attachments, ported from the web: how a picked file is classified and +//! validated (`apps/web/src/components/chat/composerAttachmentFiles.ts` and +//! `useComposerAttachmentIntake.ts`), the upload state a tile shows +//! (`apps/web/src/lib/attachmentUploadState.ts`), and the turn attachments and message context +//! a send carries (`composerContextRecords.ts`, `snapshotComposerSend`). Everything here is pure. + +use serde_json::{Value, json}; +use std::path::PathBuf; + +const MIB: u64 = 1024 * 1024; +/// `PROVIDER_SEND_TURN_MAX_ATTACHMENTS`. +pub const MAX_ATTACHMENTS: usize = 100; +/// `PROVIDER_SEND_TURN_MAX_FILE_BYTES`. +pub const MAX_FILE_BYTES: u64 = 50 * MIB; +/// `PROVIDER_SEND_TURN_MAX_IMAGE_BYTES`. +pub const MAX_IMAGE_BYTES: u64 = 10 * MIB; +/// `COMPOSER_CONTEXT_LABEL_MAX_CHARS`, counted in UTF-16 units like the web's string length. +const LABEL_MAX_UNITS: usize = 200; + +/// `ATTACHMENT_ONLY_BOOTSTRAP_PROMPT`. +pub const BOOTSTRAP_PROMPT: &str = "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + Image, + File, +} + +impl Kind { + fn wire(self) -> &'static str { + match self { + Self::Image => "image", + Self::File => "file", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Class { + Image, + File, + UnsupportedImage, +} + +/// The MIME types the providers take as images, `PROVIDER_SEND_TURN_SUPPORTED_IMAGE_MIME_TYPES`. +const IMAGE_MIME_BY_EXTENSION: [(&str, &str); 5] = [ + ("gif", "image/gif"), + ("jpeg", "image/jpeg"), + ("jpg", "image/jpeg"), + ("png", "image/png"), + ("webp", "image/webp"), +]; + +/// `VIDEO_MIME_TYPE_BY_EXTENSION` in `packages/shared/src/video.ts`. +const VIDEO_MIME_BY_EXTENSION: [(&str, &str); 7] = [ + ("avi", "video/x-msvideo"), + ("m4v", "video/mp4"), + ("mkv", "video/x-matroska"), + ("mov", "video/quicktime"), + ("mp4", "video/mp4"), + ("ogv", "video/ogg"), + ("webm", "video/webm"), +]; + +/// `GENERIC_MIME_TYPES` in `packages/shared/src/image.ts`. +const GENERIC_MIMES: [&str; 5] = [ + "application/octet-stream", + "binary/octet-stream", + "application/unknown", + "video/mp2t", + "video/vnd.dlna.mpeg-tts", +]; + +/// `WORKSPACE_IMAGE_PREVIEW_EXTENSIONS` in `packages/shared/src/mediaPreview.ts`. +const PREVIEW_EXTENSIONS: [&str; 13] = [ + ".avif", ".bmp", ".gif", ".ico", ".heic", ".heif", ".jpeg", ".jpg", ".png", ".svg", ".tif", + ".tiff", ".webp", +]; + +/// What a browser would report as `File.type` for a name: the system has only a path here. +/// An unknown extension gives the empty type a browser reports for it. +pub fn guess_mime(name: &str) -> &'static str { + const OTHER: [(&str, &str); 14] = [ + ("svg", "image/svg+xml"), + ("heic", "image/heic"), + ("heif", "image/heif"), + ("avif", "image/avif"), + ("bmp", "image/bmp"), + ("ico", "image/x-icon"), + ("tif", "image/tiff"), + ("tiff", "image/tiff"), + ("pdf", "application/pdf"), + ("json", "application/json"), + ("zip", "application/zip"), + ("txt", "text/plain"), + ("csv", "text/csv"), + ("md", "text/markdown"), + ]; + const MORE: [(&str, &str); 5] = [ + ("html", "text/html"), + ("css", "text/css"), + ("js", "text/javascript"), + ("xml", "text/xml"), + ("wav", "audio/wav"), + ]; + let Some(extension) = extension(name, true) else { + return ""; + }; + IMAGE_MIME_BY_EXTENSION + .iter() + .chain(&VIDEO_MIME_BY_EXTENSION) + .chain(&OTHER) + .chain(&MORE) + .find(|(known, _)| *known == extension) + .map_or("", |(_, mime)| mime) +} + +/// The lowercased text after the last dot. The web's image inference ignores a leading dot +/// (`dotIndex <= 0`); its video lookup does not (`dotIndex < 0`). +fn extension(name: &str, leading_dot_counts: bool) -> Option { + let dot = name.rfind('.')?; + if dot == 0 && !leading_dot_counts { + return None; + } + Some(name[dot + 1..].trim().to_lowercase()) +} + +fn base_mime(mime: &str) -> String { + mime.split(';').next().unwrap_or("").trim().to_lowercase() +} + +fn is_generic(mime: &str) -> bool { + GENERIC_MIMES.contains(&mime) +} + +/// `isSvgAttachment`. +fn is_svg(name: &str, mime: &str) -> bool { + let mime = base_mime(mime); + mime == "image/svg+xml" + || ((mime.is_empty() || is_generic(&mime)) && name.to_lowercase().ends_with(".svg")) +} + +/// `isDngFile`. +fn is_dng(name: &str, mime: &str) -> bool { + let mime = base_mime(mime); + name.to_lowercase().ends_with(".dng") + || matches!( + mime.as_str(), + "image/dng" | "image/x-dng" | "image/x-adobe-dng" + ) +} + +/// `isHeicImageFile`. +fn is_heic(name: &str, mime: &str) -> bool { + let lower = mime.to_lowercase(); + if lower == "image/heic" || lower == "image/heif" { + return true; + } + let lower_name = name.to_lowercase(); + (mime.is_empty() || lower == "application/octet-stream") + && (lower_name.ends_with(".heic") || lower_name.ends_with(".heif")) +} + +/// `inferImageMimeTypeForUnknownFile`. +fn infer_image_mime(name: &str, mime: &str) -> Option<&'static str> { + let lower = mime.to_lowercase(); + if !lower.is_empty() && lower != "application/octet-stream" { + return None; + } + let extension = extension(name, false)?; + IMAGE_MIME_BY_EXTENSION + .iter() + .find(|(known, _)| *known == extension) + .map(|(_, mime)| *mime) +} + +/// `classifyComposerAttachmentFile`. +pub fn classify(name: &str, mime: &str) -> Class { + if is_svg(name, mime) || is_dng(name, mime) { + return Class::File; + } + if is_heic(name, mime) || infer_image_mime(name, mime).is_some() { + return Class::Image; + } + let lower = mime.to_lowercase(); + if !lower.starts_with("image/") { + return Class::File; + } + if IMAGE_MIME_BY_EXTENSION + .iter() + .any(|(_, known)| *known == lower) + { + return Class::Image; + } + let lower_name = name.to_lowercase(); + if PREVIEW_EXTENSIONS + .iter() + .any(|ext| lower_name.ends_with(ext)) + { + Class::File + } else { + Class::UnsupportedImage + } +} + +/// `normalizeComposerImageFileMimeType`: the inferred type for an image whose own is unknown. +fn image_mime(name: &str, mime: &str) -> String { + infer_image_mime(name, mime).map_or_else(|| mime.to_owned(), str::to_owned) +} + +/// `composerFileAttachmentMimeType`, with `videoMimeType` from `packages/shared/src/video.ts`. +pub fn file_mime(name: &str, mime: &str) -> String { + if is_dng(name, mime) { + return "image/x-adobe-dng".to_owned(); + } + if is_svg(name, mime) { + return "image/svg+xml".to_owned(); + } + video_mime(name, mime).unwrap_or_else(|| { + if mime.is_empty() { + "application/octet-stream".to_owned() + } else { + mime.to_owned() + } + }) +} + +fn video_mime(name: &str, mime: &str) -> Option { + let mime = base_mime(mime); + if mime.starts_with("video/") && !is_generic(&mime) { + return Some(mime); + } + if !mime.is_empty() && !is_generic(&mime) { + return None; + } + let extension = extension(name, true)?; + VIDEO_MIME_BY_EXTENSION + .iter() + .find(|(known, _)| *known == extension) + .map(|(_, mime)| (*mime).to_owned()) +} + +/// `fileAttachmentStagingLimit`. `config` is the server's advertised +/// `capabilities.fileAttachments.maxUploadBytes` once its config is known: the outer `None` is +/// "not loaded yet", the inner `None` is "this server takes no files". +pub fn staging_limit(config: Option>) -> Option { + match config { + None => Some(MAX_FILE_BYTES), + Some(None) => None, + Some(Some(advertised)) => Some(advertised.min(MAX_FILE_BYTES)), + } +} + +/// `formatAttachmentSize`. +pub fn format_size(bytes: u64) -> String { + let kilobytes = bytes.div_ceil(1024).max(1); + if kilobytes >= 1024 { + format!("{:.1} MB", bytes as f64 / MIB as f64) + } else { + format!("{kilobytes} KB") + } +} + +/// `formatAttachmentUploadProgress`. +pub fn format_progress(progress: f32) -> String { + let bounded = if progress.is_finite() { + progress.clamp(0.0, 1.0) + } else { + 0.0 + }; + format!("{}%", (bounded * 100.0).floor() as u32) +} + +/// `fileAttachmentTooLargeMessage`. +pub fn too_large_message(name: &str, max_bytes: u64) -> String { + let limit = if max_bytes >= MIB && max_bytes.is_multiple_of(MIB) { + format!("{} MB", max_bytes / MIB) + } else if max_bytes >= 1024 && max_bytes.is_multiple_of(1024) { + format!("{} KB", max_bytes / 1024) + } else { + format!("{max_bytes} byte{}", if max_bytes == 1 { "" } else { "s" }) + }; + format!("'{name}' exceeds the {limit} attachment limit.") +} + +/// A file the user picked, as the picker and the file system describe it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Picked { + pub path: PathBuf, + pub name: String, + /// Zero when the file could not be read. + pub size: u64, +} + +/// A picked file that passed validation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Accepted { + pub path: PathBuf, + pub kind: Kind, + pub name: String, + pub mime: String, + pub size: u64, +} + +/// `addComposerAttachments`' validation. `reserved` is how many attachments the draft holds. +/// Like the web, one message reports: the last intake error, replaced by the last image +/// preparation error. The web's image preparation compresses and converts HEIC; here an image +/// is taken as it is, so an image over the limit or a HEIC one is refused with its messages. +pub fn intake( + picked: &[Picked], + reserved: usize, + staging_limit: Option, +) -> (Vec, Option) { + let mut reserved = reserved; + let mut accepted = Vec::new(); + let mut error = None; + let mut preparation_error = None; + for file in picked { + let mime = guess_mime(&file.name); + let class = classify(&file.name, mime); + if reserved >= MAX_ATTACHMENTS { + error = Some(format!( + "You can attach up to {MAX_ATTACHMENTS} files per message." + )); + continue; + } + match class { + Class::UnsupportedImage => { + error = Some(format!( + "'{}' is not a supported image type. Attach GIF, HEIC, HEIF, JPEG, PNG, or WebP images.", + file.name + )); + continue; + } + Class::Image => { + if file.size == 0 || is_heic(&file.name, mime) { + preparation_error = + Some(format!("'{}' could not be read as an image.", file.name)); + continue; + } + if file.size > MAX_IMAGE_BYTES { + preparation_error = Some(format!( + "'{}' is too large to attach, even after compression.", + file.name + )); + continue; + } + accepted.push(Accepted { + path: file.path.clone(), + kind: Kind::Image, + name: file.name.clone(), + mime: image_mime(&file.name, mime), + size: file.size, + }); + } + Class::File => { + let Some(limit) = staging_limit else { + error = Some("This server does not support file attachments.".to_owned()); + continue; + }; + if file.size == 0 { + error = Some(format!("'{}' is empty or could not be read.", file.name)); + continue; + } + if file.size > limit { + error = Some(too_large_message(&file.name, limit)); + continue; + } + accepted.push(Accepted { + path: file.path.clone(), + kind: Kind::File, + name: file.name.clone(), + mime: file_mime(&file.name, mime), + size: file.size, + }); + } + } + reserved += 1; + } + (accepted, preparation_error.or(error)) +} + +/// `AttachmentUploadState` of one attachment. +#[derive(Clone, Debug, PartialEq)] +pub enum Upload { + Uploading { progress: f32 }, + Ready { attachment_id: String }, + Failed { reason: String }, +} + +/// One attachment of the draft. +#[derive(Clone, Debug, PartialEq)] +pub struct Item { + /// The local id, which the context record's `contextId` is scoped from. + pub id: String, + pub path: PathBuf, + pub kind: Kind, + pub name: String, + pub mime: String, + pub size: u64, + pub upload: Upload, +} + +impl Item { + /// The id of the attachment on the server, once it has uploaded. + pub fn attachment_id(&self) -> Option<&str> { + match &self.upload { + Upload::Ready { attachment_id } => Some(attachment_id), + _ => None, + } + } +} + +/// `attachmentUploadBlockReason`: a failed upload does not block, since send retries it. +pub fn block_reason(items: &[Item]) -> Option<&'static str> { + let pending = items + .iter() + .filter(|item| matches!(item.upload, Upload::Uploading { .. })) + .count(); + match pending { + 0 => None, + 1 => Some("Attachment still uploading"), + _ => Some("Attachments still uploading"), + } +} + +/// `sanitizeComposerContextLabel`. +fn label(name: &str, kind: Kind) -> String { + let spaced: String = name + .chars() + .map(|c| { + if matches!(c, '[' | ']' | '\\' | '\r' | '\n') { + ' ' + } else { + c + } + }) + .collect(); + let normalized = spaced.split_whitespace().collect::>().join(" "); + let mut units = 0; + let mut cleaned = String::new(); + for c in normalized.chars() { + units += c.len_utf16(); + if units > LABEL_MAX_UNITS { + break; + } + cleaned.push(c); + } + if cleaned.is_empty() { + kind.wire().to_owned() + } else { + cleaned + } +} + +/// The items in the order a send carries them: images, then files (`snapshotComposerSend`). +fn ordered(items: &[Item]) -> Vec<&Item> { + let (images, files): (Vec<_>, Vec<_>) = items.iter().partition(|item| item.kind == Kind::Image); + images.into_iter().chain(files).collect() +} + +/// The turn's `attachments`, each bound to its upload. `None` while one has not uploaded +/// (`getUploadedAttachments`). +pub fn turn_attachments(items: &[Item]) -> Option> { + ordered(items) + .into_iter() + .map(|item| { + Some(json!({ + "type": item.kind.wire(), + "id": item.attachment_id()?, + "name": item.name, + "mimeType": item.mime, + "sizeBytes": item.size, + })) + }) + .collect() +} + +/// The message's `context`: one record per attachment (`buildMessageContext`); omitted when +/// there are none. +pub fn context(items: &[Item]) -> Option { + let records: Option> = ordered(items) + .into_iter() + .map(|item| { + Some(json!({ + "version": 1, + "kind": item.kind.wire(), + "contextId": format!("{}_{}", item.kind.wire(), item.id), + "label": label(&item.name, item.kind), + "attachmentId": item.attachment_id()?, + "name": item.name, + "mimeType": item.mime, + "sizeBytes": item.size, + })) + }) + .collect(); + let records = records?; + (!records.is_empty()).then(|| json!({"version": 1, "records": records})) +} + +/// What goes out as the message text: the typed text, or the bootstrap prompt when there is +/// only attachments. +pub fn message_text(text: &str) -> &str { + let trimmed = text.trim(); + if trimmed.is_empty() { + BOOTSTRAP_PROMPT + } else { + trimmed + } +} + +/// The attachment kinds and names a thread title can be derived from. +pub fn title_seed(items: &[Item]) -> Option { + let images = items.iter().filter(|item| item.kind == Kind::Image).count(); + match images { + 0 => { + let name = items + .first()? + .name + .split_whitespace() + .collect::>() + .join(" "); + (!name.is_empty()).then(|| format!("File: {name}")) + } + 1 => Some("Attached image".to_owned()), + _ => Some("Attached images".to_owned()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn picked(name: &str, size: u64) -> Picked { + Picked { + path: PathBuf::from(format!("/tmp/{name}")), + name: name.to_owned(), + size, + } + } + + fn item(id: &str, kind: Kind, name: &str, upload: Upload) -> Item { + Item { + id: id.to_owned(), + path: PathBuf::from(name), + kind, + name: name.to_owned(), + mime: "application/octet-stream".to_owned(), + size: 2048, + upload, + } + } + + fn ready(id: &str) -> Upload { + Upload::Ready { + attachment_id: id.to_owned(), + } + } + + #[test] + fn files_are_classified_like_the_web() { + let cases = [ + ("a.svg", "image/svg+xml", Class::File), + ("a.svg", "", Class::File), + ("a.dng", "image/tiff", Class::File), + ("a.heic", "image/heic", Class::Image), + ("a.HEIF", "", Class::Image), + ("a.jpg", "", Class::Image), + ("a.png", "application/octet-stream", Class::Image), + ("a.png", "image/png", Class::Image), + ("a.pdf", "application/pdf", Class::File), + ("a", "", Class::File), + ("a.tiff", "image/tiff", Class::File), + ("a.xyz", "image/x-xyz", Class::UnsupportedImage), + (".png", "", Class::File), + ]; + for (name, mime, expected) in cases { + assert_eq!(classify(name, mime), expected, "{name} as {mime:?}"); + } + } + + #[test] + fn a_file_keeps_its_type_or_gets_the_web_fallbacks() { + assert_eq!(file_mime("a.dng", ""), "image/x-adobe-dng"); + assert_eq!(file_mime("a.svg", ""), "image/svg+xml"); + assert_eq!(file_mime("a.mov", ""), "video/quicktime"); + assert_eq!(file_mime("a.ts", "video/mp2t"), "video/mp2t"); + assert_eq!(file_mime("a.bin", ""), "application/octet-stream"); + assert_eq!(file_mime("a.pdf", "application/pdf"), "application/pdf"); + assert_eq!(guess_mime("A.PNG"), "image/png"); + assert_eq!(guess_mime("notes"), ""); + } + + #[test] + fn the_staging_limit_follows_the_server_config() { + assert_eq!(staging_limit(None), Some(MAX_FILE_BYTES)); + assert_eq!(staging_limit(Some(None)), None); + assert_eq!(staging_limit(Some(Some(MIB))), Some(MIB)); + assert_eq!( + staging_limit(Some(Some(MAX_FILE_BYTES * 2))), + Some(MAX_FILE_BYTES) + ); + } + + #[test] + fn sizes_and_progress_read_like_the_web() { + assert_eq!(format_size(1), "1 KB"); + assert_eq!(format_size(1536), "2 KB"); + assert_eq!(format_size(1024 * 1023), "1023 KB"); + assert_eq!(format_size(1024 * 1023 + 600), "1.0 MB"); + assert_eq!(format_size(5 * MIB / 2), "2.5 MB"); + assert_eq!(format_progress(0.456), "45%"); + assert_eq!(format_progress(2.0), "100%"); + assert_eq!(format_progress(-1.0), "0%"); + assert_eq!(format_progress(f32::NAN), "0%"); + } + + #[test] + fn the_limit_is_spelled_in_its_own_unit() { + assert_eq!( + too_large_message("a", 50 * MIB), + "'a' exceeds the 50 MB attachment limit." + ); + assert_eq!( + too_large_message("a", 2048), + "'a' exceeds the 2 KB attachment limit." + ); + assert_eq!( + too_large_message("a", 1), + "'a' exceeds the 1 byte attachment limit." + ); + assert_eq!( + too_large_message("a", 1500), + "'a' exceeds the 1500 bytes attachment limit." + ); + } + + #[test] + fn intake_accepts_what_fits_and_reports_one_error() { + let files = [ + picked("notes.txt", 10), + picked("shot.png", 10), + picked("empty.txt", 0), + picked("big.zip", MAX_FILE_BYTES + 1), + ]; + let (accepted, error) = intake(&files, 0, Some(MAX_FILE_BYTES)); + let names: Vec<_> = accepted + .iter() + .map(|file| (file.name.as_str(), file.kind)) + .collect(); + assert_eq!( + names, + [("notes.txt", Kind::File), ("shot.png", Kind::Image)] + ); + assert_eq!(accepted[0].mime, "text/plain"); + assert_eq!(accepted[1].mime, "image/png"); + assert_eq!( + error.as_deref(), + Some("'big.zip' exceeds the 50 MB attachment limit.") + ); + } + + #[test] + fn intake_refuses_by_the_webs_rules() { + let one = + |name: &str, size, reserved, limit| intake(&[picked(name, size)], reserved, limit); + assert_eq!( + one("a.txt", 1, MAX_ATTACHMENTS, Some(MIB)).1.as_deref(), + Some("You can attach up to 100 files per message.") + ); + assert_eq!( + one("a.txt", 1, 0, None).1.as_deref(), + Some("This server does not support file attachments.") + ); + assert_eq!( + one("a.txt", 0, 0, Some(MIB)).1.as_deref(), + Some("'a.txt' is empty or could not be read.") + ); + assert_eq!( + one("a.xyz", 1, 0, Some(MIB)).1, + None, + "an unknown extension has no type, so it is a file" + ); + assert_eq!( + one("big.png", MAX_IMAGE_BYTES + 1, 0, Some(MIB)) + .1 + .as_deref(), + Some("'big.png' is too large to attach, even after compression.") + ); + assert_eq!( + one("a.heic", 5, 0, Some(MIB)).1.as_deref(), + Some("'a.heic' could not be read as an image.") + ); + assert!( + one("a.png", 1, 0, None).0.len() == 1, + "an image needs no file support" + ); + } + + #[test] + fn a_preparation_error_replaces_an_intake_error() { + let files = [picked("a.heic", 5), picked("b.txt", 0)]; + let (accepted, error) = intake(&files, 0, Some(MIB)); + assert!(accepted.is_empty()); + assert_eq!( + error.as_deref(), + Some("'a.heic' could not be read as an image.") + ); + } + + #[test] + fn only_uploading_attachments_block_send() { + let failed = Upload::Failed { + reason: "Upload failed".to_owned(), + }; + let uploading = Upload::Uploading { progress: 0.0 }; + let one = |upload: &Upload| item("1", Kind::File, "a", upload.clone()); + assert_eq!(block_reason(&[]), None); + assert_eq!(block_reason(&[one(&ready("x")), one(&failed)]), None); + assert_eq!( + block_reason(&[one(&uploading)]), + Some("Attachment still uploading") + ); + assert_eq!( + block_reason(&[one(&uploading), one(&uploading)]), + Some("Attachments still uploading") + ); + } + + #[test] + fn a_send_carries_images_then_files_bound_to_their_uploads() { + let items = [ + item("f1", Kind::File, "notes.txt", ready("up-file")), + item("i1", Kind::Image, "shot.png", ready("up-image")), + ]; + let attachments = turn_attachments(&items).unwrap(); + assert_eq!(attachments[0]["type"], "image"); + assert_eq!(attachments[0]["id"], "up-image"); + assert_eq!(attachments[1]["id"], "up-file"); + let context = context(&items).unwrap(); + assert_eq!(context["version"], 1); + assert_eq!( + context["records"][1], + json!({ + "version": 1, + "kind": "file", + "contextId": "file_f1", + "label": "notes.txt", + "attachmentId": "up-file", + "name": "notes.txt", + "mimeType": "application/octet-stream", + "sizeBytes": 2048, + }) + ); + let pending = [item( + "f1", + Kind::File, + "a", + Upload::Uploading { progress: 0.5 }, + )]; + assert_eq!(turn_attachments(&pending), None); + assert_eq!(super::context(&pending), None); + assert_eq!(super::context(&[]), None); + } + + #[test] + fn a_label_is_made_safe_for_a_link_and_cut_at_200_units() { + assert_eq!(label(" a[b]\\c\r\nd e ", Kind::File), "a b c d e"); + assert_eq!(label("[]", Kind::Image), "image"); + let long = "x".repeat(250); + assert_eq!(label(&long, Kind::File).len(), 200); + // A pair that straddles the cut is dropped whole. + let pair = format!("{}\u{1f600}", "x".repeat(199)); + assert_eq!(label(&pair, Kind::File), "x".repeat(199)); + } + + #[test] + fn empty_text_sends_the_bootstrap_prompt() { + assert_eq!(message_text(" hi \n"), "hi"); + assert_eq!(message_text(" \n"), BOOTSTRAP_PROMPT); + } + + #[test] + fn a_title_comes_from_the_attachments_when_there_is_no_text() { + let image = item("1", Kind::Image, "a.png", ready("x")); + let file = item("2", Kind::File, "notes v2.txt", ready("y")); + assert_eq!(title_seed(&[]), None); + assert_eq!( + title_seed(std::slice::from_ref(&image)).as_deref(), + Some("Attached image") + ); + assert_eq!( + title_seed(&[image.clone(), image.clone()]).as_deref(), + Some("Attached images") + ); + assert_eq!(title_seed(&[file]).as_deref(), Some("File: notes v2.txt")); + } +} diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 0b288b3e45e6..c0953f469d80 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -6,17 +6,19 @@ use crate::diff; use crate::projects::MachineKind; use crate::providers::Catalog; use crate::rpc::{ - Event, GitRunStackedAction, OrchestrationDispatchCommand, OrchestrationLaunchThread, - PING_FRAME, ProjectsMutate, ReviewGetDiffPreview, ServerGetConfig, ServerUpdateSettings, - Session, ShellOpenInEditor, SubscribeShell, SubscribeThread, SubscribeVcsStatus, - TerminalAttach, TerminalClose, TerminalResize, TerminalWrite, VcsCreateRef, VcsListRefs, - VcsPull, VcsRefreshStatus, VcsSwitchRef, + AttachmentsCreateUploadUrl, AttachmentsDelete, Event, GitRunStackedAction, + OrchestrationDispatchCommand, OrchestrationLaunchThread, PING_FRAME, ProjectsMutate, + ReviewGetDiffPreview, ServerGetConfig, ServerUpdateSettings, Session, ShellOpenInEditor, + SubscribeShell, SubscribeThread, SubscribeVcsStatus, TerminalAttach, TerminalClose, + TerminalResize, TerminalWrite, VcsCreateRef, VcsListRefs, VcsPull, VcsRefreshStatus, + VcsSwitchRef, }; use crate::shell::ShellState; use crate::status::{CallEvent, Status, StatusSender, publish}; use crate::strip::{self, Branch}; use crate::terminal::{self, Grid, Target}; use crate::thread::ThreadState; +use crate::upload::Uploader; use crate::wire::{ ProjectMutation, ProjectMutationCreateType, ProjectMutationType, ShellItem, SubscribeShellPayload, SubscribeThreadPayload, @@ -92,6 +94,14 @@ pub enum Action { thread_id: String, terminal_id: String, }, + /// Posts a file to the signed URL `attachments.createUploadUrl` minted; progress and the + /// outcome come back as `Status::Call` with `token`. + Upload { + token: u64, + relative_url: String, + path: std::path::PathBuf, + mime: String, + }, /// A request the details card makes; the outcome comes back as `Status::Call` with `token`. Call { token: u64, @@ -113,6 +123,8 @@ pub enum Call { UpdateSettings, /// A stream: progress events, then the result. RunStackedAction, + CreateUploadUrl, + DeleteAttachment, } pub type ActionSender = async_channel::Sender; @@ -133,6 +145,7 @@ impl Action { Self::TerminalWrite { .. } => "write to the terminal", Self::TerminalResize { .. } => "resize the terminal", Self::TerminalClose { .. } => "close the terminal", + Self::Upload { .. } => "upload the attachment", Self::Call { call, .. } => call.describe(), } } @@ -150,6 +163,8 @@ impl Call { Self::DispatchCommand => "send the command", Self::UpdateSettings => "update the settings", Self::RunStackedAction => "run the action", + Self::CreateUploadUrl => "prepare the upload", + Self::DeleteAttachment => "delete the attachment", } } @@ -168,6 +183,8 @@ impl Call { Self::DispatchCommand => session.request::(payload), Self::UpdateSettings => session.request::(payload), Self::RunStackedAction => session.request::(payload), + Self::CreateUploadUrl => session.request::(payload), + Self::DeleteAttachment => session.request::(payload), } } } @@ -280,6 +297,7 @@ pub async fn run( status: &StatusSender, ) -> Ended { let client = auth::http_client(); + let uploader = Uploader::new(client.clone(), http_base); let mut bearer: Option = None; // Outlives a socket: a reconnect subscribes to the thread again. let mut selected: Option = None; @@ -303,6 +321,7 @@ pub async fn run( let failure = serve( socket, environment, + &uploader, actions, (&mut selected, &mut watched, &mut terminal), status, @@ -385,6 +404,7 @@ async fn connect( async fn serve( socket: Socket, environment: &Environment, + uploader: &Uploader, actions: &ActionReceiver, (selected, watched, wanted_terminal): ( &mut Option, @@ -498,6 +518,10 @@ async fn serve( continue; } let request = match &action { + Action::Upload { token, relative_url, path, mime } => { + uploader.start(*token, relative_url, path.clone(), mime.clone(), status); + continue; + } Action::SelectThread(id) => { *selected = id.clone(); if let Err(error) = diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 5e3e9fb12cc4..da7dfb5018cc 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -1,4 +1,5 @@ mod assets; +mod attachments; mod auth; mod branch_picker; mod config; @@ -32,6 +33,7 @@ mod thread; mod thread_card; mod toast; mod ui; +mod upload; mod usage; mod wire; diff --git a/apps/desktop-gpui/src/providers.rs b/apps/desktop-gpui/src/providers.rs index c6ec3d4138b1..7fcd9a825739 100644 --- a/apps/desktop-gpui/src/providers.rs +++ b/apps/desktop-gpui/src/providers.rs @@ -66,6 +66,15 @@ const VARIANT_WORDS: &[&str] = &[ const FAMILY_PREFIXES: &[&str] = &["Claude ", "GPT-", "GPT ", "Gemini ", "Muse ", "Grok "]; +fn lenient_option<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: DeserializeOwned, +{ + Ok(Option::::deserialize(deserializer)? + .and_then(|value| serde_json::from_value(value).ok())) +} + fn lenient<'de, D, T>(deserializer: D) -> Result, D::Error> where D: Deserializer<'de>, @@ -280,6 +289,30 @@ struct Config { settings: Settings, #[serde(default, rename = "availableEditors", deserialize_with = "lenient")] available_editors: Vec, + #[serde(default, deserialize_with = "lenient_option")] + environment: Option, +} + +#[derive(Default, Deserialize)] +struct EnvironmentConfig { + #[serde(default)] + capabilities: EnvironmentCapabilities, +} + +#[derive(Default, Deserialize)] +struct EnvironmentCapabilities { + #[serde( + default, + rename = "fileAttachments", + deserialize_with = "lenient_option" + )] + file_attachments: Option, +} + +#[derive(Deserialize)] +struct FileAttachments { + #[serde(rename = "maxUploadBytes")] + max_upload_bytes: u64, } /// The provider snapshots and settings of one environment. @@ -288,6 +321,8 @@ pub struct Catalog { providers: Vec, settings: Settings, available_editors: Vec, + /// `environment.capabilities.fileAttachments.maxUploadBytes`. + max_file_upload_bytes: Option, } /// The two-letter instance chip at the icon's bottom-right. @@ -378,14 +413,23 @@ impl Catalog { providers, settings, available_editors, + environment, } = serde_json::from_value(value).map_err(|error| error.to_string())?; Ok(Self { providers, settings, available_editors, + max_file_upload_bytes: environment + .and_then(|environment| environment.capabilities.file_attachments) + .map(|files| files.max_upload_bytes), }) } + /// What the server advertises for file attachments, as `attachments::staging_limit` takes it. + pub fn file_upload_limit(&self) -> Option { + self.max_file_upload_bytes + } + /// Whether `model` on `instance_id` is starred. pub fn is_favorite(&self, instance_id: &str, model: &str) -> bool { self.settings.favorites.as_ref().is_some_and(|favorites| { @@ -1380,6 +1424,17 @@ mod tests { assert!(catalog(vec![], json!({})).available_editors().is_empty()); } + #[test] + fn the_advertised_file_upload_limit_is_read_when_present() { + let with = Catalog::decode(json!({ + "providers": [], "settings": {}, + "environment": {"capabilities": {"fileAttachments": {"maxUploadBytes": 1024}}}, + })) + .unwrap(); + assert_eq!(with.file_upload_limit(), Some(1024)); + assert_eq!(catalog(vec![], json!({})).file_upload_limit(), None); + } + #[test] fn defaults_prefer_the_project_override() { let catalog = catalog( diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs index 3c01ccc72a49..72d1cf33ef16 100644 --- a/apps/desktop-gpui/src/rpc.rs +++ b/apps/desktop-gpui/src/rpc.rs @@ -78,6 +78,24 @@ impl Method for ServerUpdateSettings { type Success = Value; } +/// `attachments.createUploadUrl`, which mints the signed URL an attachment is posted to. +pub struct AttachmentsCreateUploadUrl; + +impl Method for AttachmentsCreateUploadUrl { + const TAG: &'static str = "attachments.createUploadUrl"; + type Payload = Value; + type Success = Value; +} + +/// `attachments.delete`, which discards an uploaded attachment that no message took. +pub struct AttachmentsDelete; + +impl Method for AttachmentsDelete { + const TAG: &'static str = "attachments.delete"; + type Payload = Value; + type Success = Value; +} + /// `server.getConfig`, a unary call. The wire types are not generated; `providers::Catalog` decodes it. pub struct ServerGetConfig; diff --git a/apps/desktop-gpui/src/send.rs b/apps/desktop-gpui/src/send.rs index 2cfca2e36937..6147b03fdcdf 100644 --- a/apps/desktop-gpui/src/send.rs +++ b/apps/desktop-gpui/src/send.rs @@ -4,6 +4,7 @@ //! `message.dispatch` command. The payloads are untyped JSON like the other unary calls; the //! tests check them against `rpc.schema.json`. +use crate::attachments::{self, Item}; use crate::connection::Call; use crate::strip::{Branch, EnvMode, RuntimeMode}; use serde_json::{Value, json}; @@ -14,9 +15,12 @@ const NEW_THREAD_TITLE: &str = "New thread"; /// Both the web and the desktop shell it wraps report the web as the creation source. const CREATION_SOURCE: &str = "web"; -/// Whether the send button is live: text to send and no send already waiting on the server. -pub fn can_send(text: &str, in_flight: bool) -> bool { - !in_flight && !text.trim().is_empty() +/// `hasSendableContent`: text or an attachment to send. Send waits while an attachment uploads +/// (`attachmentUploadBlockReason`) and while a send is already waiting on the server. +pub fn can_send(text: &str, items: &[Item], in_flight: bool) -> bool { + !in_flight + && attachments::block_reason(items).is_none() + && (!text.trim().is_empty() || !items.is_empty()) } /// What the primary button does, as `ComposerPrimaryActions` picks it: a running turn with @@ -27,19 +31,24 @@ pub enum Primary { Stop, } -pub fn primary(running: bool, text: &str, in_flight: bool) -> Primary { - if running && text.trim().is_empty() { +pub fn primary(running: bool, text: &str, items: &[Item], in_flight: bool) -> Primary { + if running && text.trim().is_empty() && items.is_empty() { Primary::Stop } else { Primary::Send { - enabled: can_send(text, in_flight), + enabled: can_send(text, items, in_flight), } } } -/// `deriveThreadTitleSeed` for a text-only message: whitespace collapsed, cut at 50 characters. -pub fn title_seed(text: &str) -> String { - let normalized = text.split_whitespace().collect::>().join(" "); +/// `deriveThreadTitleSeed`: the text, else what the attachments say, whitespace collapsed and +/// cut at 50 characters. +pub fn title_seed(text: &str, items: &[Item]) -> String { + let collapse = |text: &str| text.split_whitespace().collect::>().join(" "); + let mut normalized = collapse(text); + if normalized.is_empty() { + normalized = collapse(&attachments::title_seed(items).unwrap_or_default()); + } if normalized.is_empty() { return NEW_THREAD_TITLE.to_owned(); } @@ -140,6 +149,51 @@ impl Delivery { } } +/// What the message says and carries. +#[derive(Clone, Debug, PartialEq)] +pub struct Message { + pub text: String, + pub title: String, + /// The turn's attachments, each bound to its upload. + pub attachments: Vec, + /// The message's `context` records, which the contract omits when there are none. + pub context: Option, +} + +impl Message { + pub fn build(text: &str, items: &[Item]) -> Result { + if text.trim().is_empty() && items.is_empty() { + return Err("There is nothing to send.".to_owned()); + } + let Some(attachments) = attachments::turn_attachments(items) else { + let pending = items.iter().find(|item| item.attachment_id().is_none()); + return Err(format!( + "Attachment '{}' did not finish uploading.", + pending.map_or("", |item| item.name.as_str()) + )); + }; + Ok(Self { + text: attachments::message_text(text).to_owned(), + title: title_seed(text, items), + attachments, + context: attachments::context(items), + }) + } + + /// The `{messageId, text, attachments, context}` of `orchestration.launchThread`. + fn initial(&self, message_id: &str) -> Value { + let mut message = json!({ + "messageId": message_id, + "text": self.text, + "attachments": self.attachments, + }); + if let Some(context) = &self.context { + message["context"] = context.clone(); + } + message + } +} + /// The ids a send mints; the caller supplies them so the plan stays deterministic. #[derive(Clone, Debug, PartialEq, Eq)] pub struct Ids { @@ -155,6 +209,7 @@ pub struct Composer<'a> { /// The draft's project. pub project: Option<&'a str>, pub text: &'a str, + pub attachments: &'a [Item], /// The model the trigger shows. pub model: Option>, /// Whether the user picked it, so an open thread keeps its own model otherwise. @@ -174,10 +229,7 @@ pub struct Plan { /// The first send of a draft launches its thread with the message; every later one dispatches. pub fn plan(composer: &Composer, ids: Ids) -> Result { - let text = composer.text.trim(); - if text.is_empty() { - return Err("There is nothing to send.".to_owned()); - } + let message = Message::build(composer.text, composer.attachments)?; match composer.thread { Some(thread) => Ok(Plan { call: Call::DispatchCommand, @@ -185,7 +237,7 @@ pub fn plan(composer: &Composer, ids: Ids) -> Result { &ids.command, thread, &ids.message, - text, + &message, composer.model.filter(|_| composer.picked), Delivery::of(composer.running), ), @@ -200,7 +252,7 @@ pub fn plan(composer: &Composer, ids: Ids) -> Result { payload: launch_payload( &ids, project, - text, + &message, model, composer.runtime_mode, workspace, @@ -215,7 +267,7 @@ pub fn plan(composer: &Composer, ids: Ids) -> Result { pub fn launch_payload( ids: &Ids, project_id: &str, - text: &str, + message: &Message, model: Model, runtime_mode: RuntimeMode, workspace: Workspace, @@ -225,17 +277,13 @@ pub fn launch_payload( "creationSource": CREATION_SOURCE, "threadId": ids.thread, "projectId": project_id, - "title": title_seed(text), + "title": message.title, "generateTitle": true, "modelSelection": model.selection(), "runtimeMode": runtime_mode.wire(), "interactionMode": "default", "workspaceStrategy": workspace.strategy(), - "initialMessage": { - "messageId": ids.message, - "text": text, - "attachments": [], - }, + "initialMessage": message.initial(&ids.message), }) } @@ -244,7 +292,7 @@ pub fn dispatch_payload( command_id: &str, thread_id: &str, message_id: &str, - text: &str, + message: &Message, model: Option, delivery: Delivery, ) -> Value { @@ -255,12 +303,15 @@ pub fn dispatch_payload( "creationSource": CREATION_SOURCE, "threadId": thread_id, "messageId": message_id, - "text": text, - "attachments": [], - "titleSeed": title_seed(text), + "text": message.text, + "attachments": message.attachments, + "titleSeed": message.title, "deliveryIntent": delivery.intent(), "dispatchMode": {"type": "start_immediately"}, }); + if let Some(context) = &message.context { + command["context"] = context.clone(); + } if let Some(model) = model { command["modelSelection"] = model.selection(); } @@ -282,6 +333,7 @@ pub fn favorite_payload(instance_id: &str, slug: &str, favorite: bool) -> Value #[cfg(test)] mod tests { use super::*; + use crate::attachments::{Kind, Upload}; use std::collections::BTreeSet; fn ids() -> Ids { @@ -310,6 +362,7 @@ mod tests { thread: None, project: Some("project-1"), text, + attachments: &[], model: Some(MODEL), picked: false, runtime_mode: RuntimeMode::FullAccess, @@ -413,7 +466,8 @@ mod tests { let launch = plan(&draft, ids()).unwrap().payload; assert_eq!(launch["modelSelection"]["options"], json!(options)); assert_matches_contract("orchestration.launchThread", &launch); - let dispatch = dispatch_payload("c", "t", "m", "go", Some(model), Delivery::Auto); + let message = Message::build("go", &[]).unwrap(); + let dispatch = dispatch_payload("c", "t", "m", &message, Some(model), Delivery::Auto); assert_eq!(dispatch["modelSelection"]["options"], json!(options)); assert_matches_contract("orchestration.dispatchCommand", &dispatch); } @@ -550,35 +604,150 @@ mod tests { assert_matches_contract("orchestration.dispatchCommand", &payload); } + fn attached(id: &str, upload: Upload) -> Item { + Item { + id: id.to_owned(), + path: "/tmp/notes.txt".into(), + kind: Kind::File, + name: "notes.txt".to_owned(), + mime: "text/plain".to_owned(), + size: 12, + upload, + } + } + + fn uploaded(id: &str) -> Item { + attached( + id, + Upload::Ready { + attachment_id: format!("up-{id}"), + }, + ) + } + #[test] fn send_is_enabled_by_text_and_blocked_while_in_flight() { - assert!(can_send("hi", false)); - assert!(!can_send(" \n", false)); - assert!(!can_send("hi", true)); + assert!(can_send("hi", &[], false)); + assert!(!can_send(" \n", &[], false)); + assert!(!can_send("hi", &[], true)); + } + + #[test] + fn an_attachment_alone_is_sendable_once_it_has_uploaded() { + let uploading = attached("1", Upload::Uploading { progress: 0.2 }); + let failed = attached( + "2", + Upload::Failed { + reason: "Upload failed".to_owned(), + }, + ); + assert!(can_send("", &[uploaded("3")], false)); + assert!(!can_send("hi", &[uploading], false)); + assert!( + can_send("", &[failed], false), + "send retries a failed upload" + ); } #[test] fn a_running_turn_with_an_empty_composer_offers_stop() { - assert_eq!(primary(true, "", false), Primary::Stop); - assert_eq!(primary(true, " ", true), Primary::Stop); + assert_eq!(primary(true, "", &[], false), Primary::Stop); + assert_eq!(primary(true, " ", &[], true), Primary::Stop); assert_eq!( - primary(true, "more", false), + primary(true, "more", &[], false), Primary::Send { enabled: true } ); - assert_eq!(primary(false, "", false), Primary::Send { enabled: false }); - assert_eq!(primary(false, "hi", true), Primary::Send { enabled: false }); + assert_eq!( + primary(true, "", &[uploaded("1")], false), + Primary::Send { enabled: true } + ); + assert_eq!( + primary(false, "", &[], false), + Primary::Send { enabled: false } + ); + assert_eq!( + primary(false, "hi", &[], true), + Primary::Send { enabled: false } + ); + } + + #[test] + fn attachments_ride_in_the_message_and_its_context() { + let items = [uploaded("1")]; + let mut open = composer("", Ok(Workspace::Root { branch: None })); + open.thread = Some("thread-9"); + open.attachments = &items; + let dispatch = plan(&open, ids()).unwrap().payload; + assert_eq!(dispatch["text"], attachments::BOOTSTRAP_PROMPT); + assert_eq!(dispatch["titleSeed"], "File: notes.txt"); + assert_eq!( + dispatch["attachments"], + json!([{ + "type": "file", + "id": "up-1", + "name": "notes.txt", + "mimeType": "text/plain", + "sizeBytes": 12, + }]) + ); + assert_eq!(dispatch["context"]["records"][0]["attachmentId"], "up-1"); + assert_matches_contract("orchestration.dispatchCommand", &dispatch); + + let mut draft = composer("look", Ok(Workspace::Root { branch: None })); + draft.attachments = &items; + let launch = plan(&draft, ids()).unwrap().payload; + assert_eq!(launch["title"], "look"); + assert_eq!(launch["initialMessage"]["text"], "look"); + assert_eq!( + launch["initialMessage"]["context"]["records"][0]["contextId"], + "file_1" + ); + assert_matches_contract("orchestration.launchThread", &launch); + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../packages/contracts/schema/rpc.schema.json" + ); + let schema: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + let initial = &definition(&schema, "orchestration.launchThread", &launch)["properties"]["initialMessage"] + ["anyOf"][0]; + for key in launch["initialMessage"].as_object().unwrap().keys() { + assert!(initial["properties"].get(key).is_some(), "unknown {key}"); + } + } + + #[test] + fn a_message_without_attachments_has_no_context() { + let payload = plan(&composer("go", Ok(Workspace::Root { branch: None })), ids()) + .unwrap() + .payload; + assert!(payload["initialMessage"].get("context").is_none()); + } + + #[test] + fn an_attachment_that_has_not_uploaded_does_not_send() { + let items = [attached("1", Upload::Uploading { progress: 0.5 })]; + let mut draft = composer("go", Ok(Workspace::Root { branch: None })); + draft.attachments = &items; + assert_eq!( + plan(&draft, ids()).unwrap_err(), + "Attachment 'notes.txt' did not finish uploading." + ); } #[test] fn titles_collapse_whitespace_and_cut_at_fifty_characters() { - assert_eq!(title_seed(" a \n\t b "), "a b"); - assert_eq!(title_seed(""), "New thread"); + assert_eq!(title_seed(" a \n\t b ", &[]), "a b"); + assert_eq!(title_seed("", &[]), "New thread"); let fifty = "x".repeat(50); - assert_eq!(title_seed(&fifty), fifty); - assert_eq!(title_seed(&format!("{fifty}y")), format!("{fifty}...")); + assert_eq!(title_seed(&fifty, &[]), fifty); + assert_eq!(title_seed(&format!("{fifty}y"), &[]), format!("{fifty}...")); // Characters, not bytes. let wide = "\u{e9}".repeat(51); - assert_eq!(title_seed(&wide), format!("{}...", "\u{e9}".repeat(50))); + assert_eq!( + title_seed(&wide, &[]), + format!("{}...", "\u{e9}".repeat(50)) + ); + assert_eq!(title_seed("", &[uploaded("1")]), "File: notes.txt"); } #[test] diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 546be6cbff72..fba13e075932 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -1,6 +1,7 @@ use crate::assets::{MESSAGE_SQUARE_DASHED, STAGE_DEV, STAGE_DEV_BUTTON, provider_icon}; +use crate::attachments::{self, Upload}; use crate::branch_picker::{self, BranchPicker}; -use crate::connection::{Action, ActionSender, command_id, new_project_id}; +use crate::connection::{Action, ActionSender, Call, command_id, new_project_id}; use crate::details::{self, VcsStatus}; use crate::details_card::{DetailsCard, OpenDiff, Props}; use crate::history::History; @@ -274,21 +275,39 @@ pub struct MainView { sending: Option, /// Set when a send succeeded; the next render empties the composer, which needs a window. clear_composer: bool, + /// The files attached to the composer, in the order they were picked. + attachments: Vec, + /// The mint and upload calls in flight, by token. + attachment_calls: HashMap, + attachment_token: u64, + /// Set when Send found failed uploads: they retry, and the send resumes once all settle. + resume_send: bool, actions: ActionSender, } +/// An attachment's call in flight: first the server mints the upload URL, then the file posts to it. +struct AttachmentCall { + item: String, + minted: Option, +} + /// What a successful send changes in the window. struct Sending { /// The thread a draft became. opens: Option, /// The draft's details key, whose model pick the new thread inherits. from_key: Option, + /// The attachments the message carries, which leave the composer with it. + attachments: Vec, } /// Send and stop calls, above the branch picker's range. const SEND_TOKEN: u64 = 1 << 41; const STOP_TOKEN: u64 = SEND_TOKEN + 1; const FAVORITE_TOKEN: u64 = SEND_TOKEN + 2; +const DELETE_ATTACHMENT_TOKEN: u64 = SEND_TOKEN + 3; +/// Attachment mint and upload calls, one token each, above the send calls. +const ATTACH_TOKEN: u64 = 1 << 42; impl MainView { pub fn new( @@ -417,6 +436,10 @@ impl MainView { main_width: Cell::new(0.), sending: None, clear_composer: false, + attachments: Vec::new(), + attachment_calls: HashMap::new(), + attachment_token: ATTACH_TOKEN, + resume_send: false, actions, } } @@ -507,6 +530,10 @@ impl MainView { return cx.notify(); } if let Status::Call { token, event } = status { + if token >= ATTACH_TOKEN { + self.on_attachment_call(token, event, cx); + return; + } if token >= SEND_TOKEN { self.on_send_call(token, event, cx); return; @@ -550,9 +577,22 @@ impl MainView { /// Sends the composer: a draft launches its thread, an open thread takes a message. fn submit(&mut self, cx: &mut Context) { let text = self.composer.read(cx).value().to_string(); - if !send::can_send(&text, self.sending.is_some()) { + if !send::can_send(&text, &self.attachments, self.sending.is_some()) { return; } + let failed: Vec = self + .attachments + .iter() + .filter(|item| matches!(item.upload, Upload::Failed { .. })) + .map(|item| item.id.clone()) + .collect(); + if !failed.is_empty() { + for id in &failed { + self.start_upload(id, cx); + } + self.resume_send = true; + return cx.notify(); + } let planned = { let project = self .shell() @@ -592,6 +632,7 @@ impl MainView { defaults.start_from_origin, ), running: self.thread_running(), + attachments: &self.attachments, }; send::plan( &composer, @@ -622,10 +663,369 @@ impl MainView { self.sending = Some(Sending { opens: plan.opens, from_key, + attachments: self + .attachments + .iter() + .map(|item| item.id.clone()) + .collect(), }); cx.notify(); } + /// The attach button: the native picker stands in for the web's file input. + fn attach_files(&mut self, cx: &mut Context) { + let picked = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: true, + prompt: Some("Attach files".into()), + }); + cx.spawn(async move |this, cx| { + let result = picked.await; + this.update(cx, |this, cx| match result { + Ok(Ok(Some(paths))) => this.add_attachments(paths, cx), + Ok(Err(error)) => this.error_toast("Failed to attach files", error.to_string(), cx), + _ => {} + }) + .ok(); + }) + .detach(); + } + + /// The web's `addComposerAttachments`: validate, stage and start uploading each file. + fn add_attachments(&mut self, paths: Vec, cx: &mut Context) { + let picked: Vec = paths + .into_iter() + .map(|path| attachments::Picked { + name: path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default(), + size: std::fs::metadata(&path).map_or(0, |meta| meta.len()), + path, + }) + .collect(); + let limit = attachments::staging_limit( + self.providers + .as_ref() + .map(|catalog| catalog.file_upload_limit()), + ); + let (accepted, error) = attachments::intake(&picked, self.attachments.len(), limit); + if let Some(message) = error { + self.message_toast(message, cx); + } + for file in accepted { + let id = new_project_id(); + self.attachments.push(attachments::Item { + id: id.clone(), + path: file.path, + kind: file.kind, + name: file.name, + mime: file.mime, + size: file.size, + upload: Upload::Uploading { progress: 0. }, + }); + self.start_upload(&id, cx); + } + cx.notify(); + } + + /// Asks the server for an upload URL; `on_attachment_call` posts the file once it answers. + fn start_upload(&mut self, id: &str, cx: &mut Context) { + let Some(item) = self.attachments.iter_mut().find(|item| item.id == id) else { + return; + }; + item.upload = Upload::Uploading { progress: 0. }; + let mut payload = serde_json::json!({ + "name": item.name, + "mimeType": item.mime, + "sizeBytes": item.size, + }); + if item.kind == attachments::Kind::File { + payload["type"] = "file".into(); + } + self.attachment_token += 1; + let token = self.attachment_token; + let sent = self.actions.try_send(Action::Call { + token, + call: Call::CreateUploadUrl, + payload, + }); + if sent.is_ok() { + self.attachment_calls.insert( + token, + AttachmentCall { + item: id.to_owned(), + minted: None, + }, + ); + } else { + self.fail_upload(id, "Upload could not start".to_owned()); + } + cx.notify(); + } + + fn fail_upload(&mut self, id: &str, reason: String) { + if let Some(item) = self.attachments.iter_mut().find(|item| item.id == id) { + item.upload = Upload::Failed { reason }; + } + } + + fn delete_attachment(&mut self, attachment_id: &str) { + self.actions + .try_send(Action::Call { + token: DELETE_ATTACHMENT_TOKEN, + call: Call::DeleteAttachment, + payload: serde_json::json!({ "attachmentId": attachment_id }), + }) + .ok(); + } + + fn on_attachment_call(&mut self, token: u64, event: CallEvent, cx: &mut Context) { + let Some(call) = self.attachment_calls.get(&token) else { + return; + }; + let (item, minted) = (call.item.clone(), call.minted.clone()); + let result = match event { + CallEvent::Progress(progress) => { + let fraction = progress.as_f64().unwrap_or_default() as f32; + if let Some(entry) = self.attachments.iter_mut().find(|entry| entry.id == item) { + entry.upload = Upload::Uploading { progress: fraction }; + } + return cx.notify(); + } + CallEvent::Done(value) => Ok(value), + CallEvent::Failed(reason) => Err(reason), + }; + self.attachment_calls.remove(&token); + let present = self.attachments.iter().any(|entry| entry.id == item); + match (minted, result) { + (None, Ok(value)) => { + let url = value.get("relativeUrl").and_then(|url| url.as_str()); + let id = value.get("attachmentId").and_then(|id| id.as_str()); + let entry = self.attachments.iter().find(|entry| entry.id == item); + match (url, id, entry) { + (Some(url), Some(id), Some(entry)) => { + let action = Action::Upload { + token, + relative_url: url.to_owned(), + path: entry.path.clone(), + mime: entry.mime.clone(), + }; + if self.actions.try_send(action).is_ok() { + self.attachment_calls.insert( + token, + AttachmentCall { + item, + minted: Some(id.to_owned()), + }, + ); + } else { + self.fail_upload(&item, "Upload could not start".to_owned()); + } + } + (_, _, None) => {} + _ => self.fail_upload(&item, "Upload could not start".to_owned()), + } + } + (None, Err(_)) => self.fail_upload(&item, "Upload could not start".to_owned()), + (Some(attachment_id), Ok(_)) => { + if present { + if let Some(entry) = self.attachments.iter_mut().find(|entry| entry.id == item) + { + entry.upload = Upload::Ready { attachment_id }; + } + } else { + self.delete_attachment(&attachment_id); + } + } + (Some(attachment_id), Err(reason)) => { + self.delete_attachment(&attachment_id); + self.fail_upload(&item, reason); + } + } + self.resume_pending_send(cx); + cx.notify(); + } + + /// Resumes a send that retried failed uploads once none is still uploading. + fn resume_pending_send(&mut self, cx: &mut Context) { + if !self.resume_send + || self + .attachments + .iter() + .any(|item| matches!(item.upload, Upload::Uploading { .. })) + { + return; + } + self.resume_send = false; + if self + .attachments + .iter() + .any(|item| matches!(item.upload, Upload::Failed { .. })) + { + return self.message_toast( + "Retry or remove failed uploads before sending.".to_owned(), + cx, + ); + } + self.submit(cx); + } + + fn remove_attachment(&mut self, id: &str, cx: &mut Context) { + let Some(index) = self.attachments.iter().position(|item| item.id == id) else { + return; + }; + let item = self.attachments.remove(index); + if let Some(attachment_id) = item.attachment_id() { + self.delete_attachment(attachment_id); + } + self.resume_pending_send(cx); + cx.notify(); + } + + fn message_toast(&mut self, message: String, cx: &mut Context) { + let toast = Toast::new(message).kind(ToastKind::Error); + self.toasts.update(cx, |toasts, cx| { + toasts.add(toast, cx); + }); + } + + /// The web's `ComposerAttachmentMediaRow` and `ComposerAttachmentFileList`. + fn attachment_strip(&self, cx: &mut Context) -> Option { + if self.attachments.is_empty() { + return None; + } + let theme = cx.theme(); + let (border, background, foreground, muted, accent) = ( + theme.border.opacity(0.8), + theme.background, + theme.foreground, + theme.muted_foreground, + theme.accent, + ); + let ghost = StripButton::Ghost { + icon: muted, + hover: accent, + }; + let mut tiles = Vec::new(); + let mut rows = Vec::new(); + for item in &self.attachments { + let remove_id = item.id.clone(); + let retry_id = item.id.clone(); + let progress = match &item.upload { + Upload::Uploading { progress } => Some(attachments::format_progress(*progress)), + _ => None, + }; + let failed = match &item.upload { + Upload::Failed { reason } => Some(reason.clone()), + _ => None, + }; + let image = item.kind == attachments::Kind::Image; + let remove = attachment_button( + format!("attachment-remove-{}", item.id), + Icons::X, + format!("Remove {}", item.name), + if image { + StripButton::MediaClose + } else { + ghost + }, + ) + .on_click(cx.listener(move |this, _, _, cx| this.remove_attachment(&remove_id, cx))); + let retry = failed.map(|reason| { + attachment_button( + format!("attachment-retry-{}", item.id), + Icons::RefreshCw, + reason, + if image { StripButton::Overlay } else { ghost }, + ) + .on_click(cx.listener(move |this, _, _, cx| { + this.start_upload(&retry_id, cx); + this.resume_send = false; + })) + }); + if image { + tiles.push( + div() + .id(SharedString::from(format!("attachment-{}", item.id))) + .relative() + .flex_shrink_0() + .size(px(64.)) + .overflow_hidden() + .rounded(px(8.)) + .border_1() + .border_color(border) + .bg(background) + .child( + img(item.path.clone()) + .size_full() + .object_fit(ObjectFit::Cover), + ) + .children(progress.map(|label| { + div() + .absolute() + .bottom_0() + .left_0() + .right_0() + .px_1() + .text_center() + .text_size(px(10.)) + .text_color(foreground) + .bg(background.opacity(0.85)) + .child(label) + })) + .children( + retry.map(|retry| div().absolute().bottom_1().left_1().child(retry)), + ) + .child(div().absolute().top_1().right_1().child(remove)) + .into_any_element(), + ); + } else { + let detail = progress.unwrap_or_else(|| attachments::format_size(item.size)); + rows.push( + h_flex() + .min_w_0() + .items_center() + .gap_2() + .py_1() + .text_sm() + .text_color(foreground) + .child(Icon::new(Icons::File).size_4().text_color(muted)) + .child( + div() + .min_w_0() + .flex_1() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .child(item.name.clone()), + ) + .child( + div() + .flex_shrink_0() + .text_xs() + .text_color(muted) + .child(detail), + ) + .children(retry) + .child(remove) + .into_any_element(), + ); + } + } + Some( + v_flex() + .mb_3() + .gap_1() + .when(!tiles.is_empty(), |strip| { + strip.child(h_flex().flex_wrap().gap_2().children(tiles)) + }) + .children(rows) + .into_any_element(), + ) + } + /// The web's stop button: `thread.stop` for the open thread. fn stop(&mut self, cx: &mut Context) { let Some(thread) = self.selected_thread.clone() else { @@ -663,6 +1063,9 @@ impl MainView { } return; } + if token == DELETE_ATTACHMENT_TOKEN { + return; + } let Some(sent) = self.sending.take() else { return; }; @@ -671,6 +1074,8 @@ impl MainView { return cx.notify(); } self.clear_composer = true; + self.attachments + .retain(|item| !sent.attachments.contains(&item.id)); if let Some(thread) = sent.opens { if let Some(pick) = sent.from_key.and_then(|key| self.model_picks.remove(&key)) { self.model_picks.insert(thread.clone(), pick); @@ -3608,6 +4013,7 @@ impl MainView { let primary = send::primary( self.thread_running(), &self.composer.read(cx).value(), + &self.attachments, self.sending.is_some(), ); v_flex() @@ -3620,28 +4026,36 @@ impl MainView { .bg(composer_surface(cx.theme().is_dark())) .child( // The web's `px-4 pt-4 pb-2` body; the negative margin cancels the textarea's own padding. - div().px_4().pt_4().pb_2().child( - Textarea::new(&self.composer) - .appearance(false) - .bordered(false) - .mx(px(-10.)) - .my(px(-8.)) - // The web's `leading-relaxed` at 14px. - .line_height(px(22.75)), - ), + div() + .px_4() + .pt_4() + .pb_2() + .children(self.attachment_strip(cx)) + .child( + Textarea::new(&self.composer) + .appearance(false) + .bordered(false) + .mx(px(-10.)) + .my(px(-8.)) + // The web's `leading-relaxed` at 14px. + .line_height(px(22.75)), + ), ) .child( h_flex() .px_2() .pb_2() .justify_between() - .child(header_icon_button( - "attach", - gpui_kit_assets::IconName::Paperclip, - "Attach files".into(), - true, - cx, - )) + .child( + header_icon_button( + "attach", + gpui_kit_assets::IconName::Paperclip, + "Attach files".into(), + true, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| this.attach_files(cx))), + ) .child( h_flex().gap_2().children(model_picker).child( send_button(primary, self.send_hovered, cx) @@ -3659,6 +4073,52 @@ impl MainView { } } +/// The strip's 20px remove and retry control. +fn attachment_button( + id: String, + icon: Icons, + label: String, + look: StripButton, +) -> gpui::Stateful { + let black = gpui::black(); + let white = gpui::white(); + let button = div() + .id(SharedString::from(id)) + .flex() + .items_center() + .justify_center() + .size(px(24.)) + .rounded(px(6.)) + .border_1() + .cursor_pointer() + .tooltip(tooltip(label)); + let button = match look { + StripButton::MediaClose => button + .border_color(white.opacity(0.2)) + .bg(black.opacity(0.65)) + .text_color(white) + .hover(move |style| style.bg(black.opacity(0.8))), + StripButton::Overlay => button + .border_color(gpui::transparent_black()) + .bg(black.opacity(0.7)) + .text_color(white.opacity(0.65)) + .hover(move |style| style.bg(black.opacity(0.9))), + StripButton::Ghost { icon, hover } => button + .border_color(gpui::transparent_black()) + .text_color(icon) + .hover(move |style| style.bg(hover)), + }; + button.child(Icon::new(icon).size(px(14.))) +} + +/// The web `Button` variants the attachment strip uses at `icon-xs`. +#[derive(Clone, Copy)] +enum StripButton { + MediaClose, + Overlay, + Ghost { icon: Hsla, hover: Hsla }, +} + /// The web's `ComposerControl` at `xs`: a ghost button with a 12px icon and a 12px label. fn strip_control( id: &'static str, diff --git a/apps/desktop-gpui/src/upload.rs b/apps/desktop-gpui/src/upload.rs new file mode 100644 index 000000000000..47105c0779b8 --- /dev/null +++ b/apps/desktop-gpui/src/upload.rs @@ -0,0 +1,174 @@ +//! The HTTP half of an attachment upload, as the web's `runAttachmentUploadCycle` does it +//! (`apps/web/src/lib/attachmentUploadQueue.ts`): the bytes are POSTed to the signed URL that +//! `attachments.createUploadUrl` minted, with the attachment's MIME type as `Content-Type`. + +use crate::status::{CallEvent, Status, StatusSender, publish}; +use bytes::Bytes; +use http_body::{Body as HttpBody, Frame, SizeHint}; +use serde_json::{Value, json}; +use std::convert::Infallible; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; +use std::task::{Context, Poll}; +use std::time::Duration; +use tokio::sync::Semaphore; + +/// `MAX_CONCURRENT_UPLOADS`. +const CONCURRENCY: usize = 3; +/// `UPLOAD_TIMEOUT_MS`. +const TIMEOUT: Duration = Duration::from_secs(5 * 60); +const CHUNK: usize = 64 * 1024; + +/// Posts attachments to one environment, a few at a time. +pub struct Uploader { + client: reqwest::Client, + base: String, + slots: Arc, +} + +impl Uploader { + pub fn new(client: reqwest::Client, base: &str) -> Self { + Self { + client, + base: base.trim_end_matches('/').to_owned(), + slots: Arc::new(Semaphore::new(CONCURRENCY)), + } + } + + /// Posts `path` to `relative_url`, answering on `token` with progress, then done or failed. + pub fn start( + &self, + token: u64, + relative_url: &str, + path: PathBuf, + mime: String, + status: &StatusSender, + ) { + let client = self.client.clone(); + let url = format!("{}{relative_url}", self.base); + let slots = self.slots.clone(); + let status = status.clone(); + tokio::spawn(async move { + let _slot = slots.acquire_owned().await; + let outcome = tokio::time::timeout( + TIMEOUT, + post(&client, &url, path, &mime, token, status.clone()), + ) + .await + .unwrap_or_else(|_| Err("Upload timed out".to_owned())); + let event = match outcome { + Ok(()) => CallEvent::Done(Value::Null), + Err(reason) => CallEvent::Failed(reason), + }; + publish(&status, Status::Call { token, event }); + }); + } +} + +async fn post( + client: &reqwest::Client, + url: &str, + path: PathBuf, + mime: &str, + token: u64, + status: StatusSender, +) -> Result<(), String> { + let data = tokio::task::spawn_blocking(move || std::fs::read(path)) + .await + .map_err(|_| "Upload failed".to_owned())? + .map_err(|_| "Upload failed".to_owned())?; + let body = Progress { + data: Bytes::from(data), + sent: 0, + token, + status, + }; + let response = client + .post(url) + .header(reqwest::header::CONTENT_TYPE, mime) + .body(reqwest::Body::wrap(body)) + .send() + .await + .map_err(|_| "Upload failed".to_owned())?; + let code = response.status(); + if code.is_success() { + Ok(()) + } else { + Err(format!("Upload rejected ({})", code.as_u16())) + } +} + +/// The request body, handed out in chunks so each one reports how far the upload has got. +struct Progress { + data: Bytes, + sent: usize, + token: u64, + status: StatusSender, +} + +impl HttpBody for Progress { + type Data = Bytes; + type Error = Infallible; + + fn poll_frame( + mut self: Pin<&mut Self>, + _: &mut Context<'_>, + ) -> Poll, Infallible>>> { + let total = self.data.len(); + if self.sent >= total { + return Poll::Ready(None); + } + let end = (self.sent + CHUNK).min(total); + let chunk = self.data.slice(self.sent..end); + self.sent = end; + publish( + &self.status, + Status::Call { + token: self.token, + event: CallEvent::Progress(json!(end as f64 / total as f64)), + }, + ); + Poll::Ready(Some(Ok(Frame::data(chunk)))) + } + + fn is_end_stream(&self) -> bool { + self.sent >= self.data.len() + } + + fn size_hint(&self) -> SizeHint { + SizeHint::with_exact((self.data.len() - self.sent) as u64) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use http_body_util::BodyExt; + + #[tokio::test] + async fn the_body_reports_each_chunk_and_ends_with_the_whole_file() { + let (status, updates) = async_channel::unbounded(); + let total = CHUNK * 2 + 10; + let body = Progress { + data: Bytes::from(vec![7u8; total]), + sent: 0, + token: 9, + status, + }; + assert_eq!(body.size_hint().exact(), Some(total as u64)); + let collected = body.collect().await.unwrap().to_bytes(); + assert_eq!(collected.len(), total); + let mut fractions = Vec::new(); + while let Ok(Status::Call { token, event }) = updates.try_recv() { + assert_eq!(token, 9); + let CallEvent::Progress(value) = event else { + panic!("not progress"); + }; + fractions.push(value.as_f64().unwrap()); + } + assert_eq!(fractions.len(), 3); + assert_eq!(fractions.last(), Some(&1.0)); + assert!(fractions.windows(2).all(|pair| pair[0] < pair[1])); + } +} From ca085aabc73ff26b9f80469aaa8af1d77cd264bb Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 8 Nov 2026 01:12:29 -0500 Subject: [PATCH 56/82] feat(desktop-gpui): attached files become inline composer chips The web inserts a [name](t3-context://v1/file/) reference at the cursor for each attached file and renders it as a chip; the GPUI composer only listed files under the strip. Attached files now insert atomic inline tokens with the web's kind icons, accents, hover underline and selection highlight, images get chips only when the prompt already has text, and files referenced inline leave the strip. --- apps/desktop-gpui/src/assets.rs | 61 +++ apps/desktop-gpui/src/attachments.rs | 503 +++++++++++++++++- apps/desktop-gpui/src/chip-icons/epub.svg | 1 + apps/desktop-gpui/src/chip-icons/image.svg | 1 + apps/desktop-gpui/src/chip-icons/pdf.svg | 1 + apps/desktop-gpui/src/chip-icons/slides.svg | 1 + .../src/chip-icons/spreadsheet.svg | 1 + apps/desktop-gpui/src/chip-icons/video.svg | 1 + apps/desktop-gpui/src/chip-icons/word.svg | 1 + apps/desktop-gpui/src/ui.rs | 242 ++++++++- 10 files changed, 796 insertions(+), 17 deletions(-) create mode 100644 apps/desktop-gpui/src/chip-icons/epub.svg create mode 100644 apps/desktop-gpui/src/chip-icons/image.svg create mode 100644 apps/desktop-gpui/src/chip-icons/pdf.svg create mode 100644 apps/desktop-gpui/src/chip-icons/slides.svg create mode 100644 apps/desktop-gpui/src/chip-icons/spreadsheet.svg create mode 100644 apps/desktop-gpui/src/chip-icons/video.svg create mode 100644 apps/desktop-gpui/src/chip-icons/word.svg diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 8d46599584e9..38f407d66fe2 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -1,3 +1,4 @@ +use crate::attachments::ChipKind; use gpui::{AssetSource, Result, SharedString}; use std::borrow::Cow; @@ -209,6 +210,53 @@ pub fn editor_icon(editor: &str, dark: bool) -> Option<&'static str> { } } +/// The lucide icons the web's `pierre-icons.ts` sprite draws for documents and video, plus the +/// image icon of `ImageChipButton`. +const CHIP_ICONS: &[(&str, &[u8])] = &[ + ( + "chip/pdf.svg", + include_bytes!("chip-icons/pdf.svg") as &[u8], + ), + ( + "chip/word.svg", + include_bytes!("chip-icons/word.svg") as &[u8], + ), + ( + "chip/spreadsheet.svg", + include_bytes!("chip-icons/spreadsheet.svg") as &[u8], + ), + ( + "chip/slides.svg", + include_bytes!("chip-icons/slides.svg") as &[u8], + ), + ( + "chip/epub.svg", + include_bytes!("chip-icons/epub.svg") as &[u8], + ), + ( + "chip/video.svg", + include_bytes!("chip-icons/video.svg") as &[u8], + ), + ( + "chip/image.svg", + include_bytes!("chip-icons/image.svg") as &[u8], + ), +]; + +/// The icon asset of a chip kind, or `None` for a file without a document icon. +pub fn chip_icon(kind: ChipKind) -> Option<&'static str> { + Some(match kind { + ChipKind::File => return None, + ChipKind::Image => "chip/image.svg", + ChipKind::Video => "chip/video.svg", + ChipKind::Pdf => "chip/pdf.svg", + ChipKind::Word => "chip/word.svg", + ChipKind::Spreadsheet => "chip/spreadsheet.svg", + ChipKind::Slides => "chip/slides.svg", + ChipKind::Epub => "chip/epub.svg", + }) +} + pub struct AppAssets; impl AssetSource for AppAssets { @@ -225,6 +273,9 @@ impl AssetSource for AppAssets { if let Some((_, bytes)) = PROVIDER_ICONS.iter().find(|(known, _)| *known == path) { return Ok(Some(Cow::Borrowed(bytes))); } + if let Some((_, bytes)) = CHIP_ICONS.iter().find(|(known, _)| *known == path) { + return Ok(Some(Cow::Borrowed(bytes))); + } if let Some((_, bytes)) = EDITOR_ICONS.iter().find(|(known, _)| *known == path) { return Ok(Some(Cow::Borrowed(bytes))); } @@ -249,10 +300,20 @@ mod tests { use super::*; use gpui_kit_assets::IconName; + #[test] + fn every_chip_icon_loads() { + for (path, _) in CHIP_ICONS { + assert!(AppAssets.load(path).unwrap().is_some(), "{path}"); + } + assert!(chip_icon(ChipKind::File).is_none()); + assert!(chip_icon(ChipKind::Pdf).is_some()); + } + #[test] fn every_catalog_icon_the_app_uses_loads() { for icon in [ IconName::Paperclip, + IconName::File, IconName::SquareMenu, IconName::PanelBottom, IconName::SquareSplitHorizontal, diff --git a/apps/desktop-gpui/src/attachments.rs b/apps/desktop-gpui/src/attachments.rs index 8a93d6e3d172..232e1d7145e4 100644 --- a/apps/desktop-gpui/src/attachments.rs +++ b/apps/desktop-gpui/src/attachments.rs @@ -402,6 +402,20 @@ pub struct Item { } impl Item { + /// `fileContextReference` / `imageContextReference`: the id the message text refers to. + pub fn context_id(&self) -> String { + kind_scoped_context_id(self.kind.wire(), &self.id) + } + + /// The inline reference this item is written as in the prompt. + pub fn reference(&self) -> String { + format_reference( + self.kind.wire(), + &self.context_id(), + &label(&self.name, self.kind), + ) + } + /// The id of the attachment on the server, once it has uploaded. pub fn attachment_id(&self) -> Option<&str> { match &self.upload { @@ -426,6 +440,10 @@ pub fn block_reason(items: &[Item]) -> Option<&'static str> { /// `sanitizeComposerContextLabel`. fn label(name: &str, kind: Kind) -> String { + sanitize_label(name, kind.wire()) +} + +fn sanitize_label(name: &str, kind: &str) -> String { let spaced: String = name .chars() .map(|c| { @@ -447,12 +465,324 @@ fn label(name: &str, kind: Kind) -> String { cleaned.push(c); } if cleaned.is_empty() { - kind.wire().to_owned() + kind.to_owned() } else { cleaned } } +const HREF_PREFIX: &str = "t3-context://v1/"; +const MAX_LINK_LABEL_UNITS: usize = 512; +const MAX_HREF_UNITS: usize = 200; + +/// `fnv1a64` of `composerContextReferences.ts`, over UTF-16 units like `charCodeAt`. +fn fnv1a64(value: &str) -> String { + let units: Vec = value.encode_utf16().collect(); + let (mut forward, mut reverse) = (0x811c_9dc5_u32, 0x9dc5_811c_u32); + for index in 0..units.len() { + forward ^= u32::from(units[index]); + forward = forward.wrapping_mul(0x0100_0193); + reverse ^= u32::from(units[units.len() - 1 - index]); + reverse = reverse.wrapping_mul(0x0100_0193); + } + format!("{forward:08x}{reverse:08x}") +} + +fn is_context_id(value: &str) -> bool { + (1..=128).contains(&value.len()) + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +fn is_context_kind(value: &str) -> bool { + let mut chars = value.chars(); + value.len() <= 40 + && chars.next().is_some_and(|c| c.is_ascii_lowercase()) + && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +/// `toComposerContextId`. +pub fn to_context_id(producer: &str) -> String { + if is_context_id(producer) { + return producer.to_owned(); + } + let mut slug = String::new(); + let mut pending_dash = false; + for c in producer.chars() { + if c.is_ascii_alphanumeric() || c == '_' || c == '-' { + if pending_dash { + slug.push('-'); + pending_dash = false; + } + slug.push(c); + } else { + pending_dash = true; + } + } + let slug = slug.trim_matches('-'); + let slug: String = slug.chars().take(48).collect(); + let slug = if slug.is_empty() { "ctx" } else { &slug }; + format!("{slug}-{}", fnv1a64(producer)) +} + +/// `toKindScopedComposerContextId`. +pub fn kind_scoped_context_id(kind: &str, producer: &str) -> String { + to_context_id(&format!("{kind}_{producer}")) +} + +/// `formatComposerContextHref`. +fn format_href(kind: &str, context_id: &str) -> String { + format!("{HREF_PREFIX}{kind}/{context_id}") +} + +/// `parseComposerContextHref`: the kind and id of an href, or `None` when it is not exactly +/// scheme, version, kind and id. +fn parse_href(href: &str) -> Option<(&str, &str)> { + let (kind, id) = href.strip_prefix(HREF_PREFIX)?.split_once('/')?; + (is_context_kind(kind) && is_context_id(id)).then_some((kind, id)) +} + +/// `formatComposerContextReference`. +pub fn format_reference(kind: &str, context_id: &str, label: &str) -> String { + let bang = if kind == "image" { "!" } else { "" }; + format!("{bang}[{label}]({})", format_href(kind, context_id)) +} + +/// `ComposerContextReferenceOccurrence`, with byte offsets. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Reference { + pub kind: String, + pub context_id: String, + pub label: String, + pub image: bool, + pub start: usize, + pub end: usize, +} + +/// The reference link a `[` at `open` starts, as the `CONTEXT_LINK` pattern reads it: the end +/// of the match, the label and the href. +fn link_at(text: &str, open: usize) -> Option<(usize, &str, &str)> { + let body = &text[open + 1..]; + let mut units = 0; + let mut close = None; + for (index, c) in body.char_indices() { + if c == ']' { + close = Some(index); + break; + } + units += c.len_utf16(); + if c == '\n' || units > MAX_LINK_LABEL_UNITS { + return None; + } + } + let close = close?; + let label = &body[..close]; + let after = body[close + 1..].strip_prefix('(')?; + if !after.starts_with(HREF_PREFIX) { + return None; + } + let tail = &after[HREF_PREFIX.len()..]; + let run = tail + .find(|c: char| c.is_whitespace() || c == ')') + .unwrap_or(tail.len()); + if run == 0 + || tail[..run].encode_utf16().count() > MAX_HREF_UNITS + || !tail[run..].starts_with(')') + { + return None; + } + let href = &after[..HREF_PREFIX.len() + run]; + let end = open + 1 + close + 1 + 1 + href.len() + 1; + Some((end, label, href)) +} + +/// `collectComposerContextReferences`. +pub fn collect_references(text: &str) -> Vec { + let mut found = Vec::new(); + if !text.contains("](t3-context:") { + return found; + } + let mut cursor = 0; + for (open, _) in text.match_indices('[') { + if open < cursor { + continue; + } + let Some((end, raw_label, href)) = link_at(text, open) else { + continue; + }; + let Some((kind, context_id)) = parse_href(href) else { + continue; + }; + let image = open > cursor && text[..open].ends_with('!'); + found.push(Reference { + kind: kind.to_owned(), + context_id: context_id.to_owned(), + label: sanitize_label(raw_label, kind), + image, + start: if image { open - 1 } else { open }, + end, + }); + cursor = end; + } + found +} + +/// `stripInlineContextReferences`. +pub fn strip_references(text: &str) -> String { + let mut result = String::new(); + let mut cursor = 0; + for reference in collect_references(text) { + result.push_str(&text[cursor..reference.start]); + cursor = reference.end; + } + result.push_str(&text[cursor..]); + result +} + +/// `removeInlineContextReference`: the edits, last first, that take every reference to +/// `context_id` out of `text` together with one neighbouring space, then trim a tail the removal +/// left at the end. +pub fn removal_ranges(text: &str, context_id: &str) -> Vec> { + let mut result = text.to_owned(); + let mut ranges = Vec::new(); + let found: Vec<_> = collect_references(text) + .into_iter() + .filter(|reference| reference.context_id == context_id) + .collect(); + let mut cursor = text.len(); + for reference in found.iter().rev() { + let (mut start, mut end) = (reference.start, reference.end); + if result[end..].starts_with(' ') { + end += 1; + } else if result[..start].ends_with(' ') { + start -= 1; + } + result.replace_range(start..end, ""); + ranges.push(start..end); + cursor = start; + } + if !ranges.is_empty() && cursor >= result.len() { + let kept = result.trim_end().len(); + if kept < result.len() { + ranges.push(kept..result.len()); + } + } + ranges +} + +/// What attaching writes into the prompt around the references, per `inlineContextReferenceReplacement` +/// and `insertComposerText`: each reference is followed by one space, and one more space leads +/// the first unless the text before the cursor already ends in whitespace or starts there. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Insertion { + pub leading_space: bool, + /// The text after the cursor already starts with a space, which the last reference reuses. + pub reuse_next_space: bool, +} + +pub fn insertion(before: Option, after: Option) -> Insertion { + Insertion { + leading_space: before.is_some_and(|c| !c.is_whitespace()), + reuse_next_space: after == Some(' '), + } +} + +/// `imageAttachmentsGetChips` without a drop selection: images only get a chip once the prompt +/// has text of its own, or a pending question or project selection takes the attachment. +pub fn images_get_chips(prompt: &str, question_or_project_pending: bool) -> bool { + question_or_project_pending || !strip_references(prompt).trim().is_empty() +} + +/// `middleTruncateAttachmentName`. +pub fn middle_truncate_name(name: &str, max_characters: usize) -> String { + let characters: Vec = name.chars().collect(); + if characters.len() <= max_characters { + return name.to_owned(); + } + match max_characters { + 0 => return String::new(), + 1 => return "…".to_owned(), + _ => {} + } + let available = max_characters - 1; + let suffix_len = (available - 1).min(if available >= 18 { + 14 + } else { + available.div_ceil(2) + }); + let prefix_len = available - suffix_len; + let prefix: String = characters[..prefix_len].iter().collect(); + let suffix: String = characters[characters.len() - suffix_len..].iter().collect(); + format!("{prefix}…{suffix}") +} + +/// The `ContextChip` kinds an attachment takes (`FileChip`, `documentChipKindForFileName`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChipKind { + File, + Image, + Video, + Pdf, + Word, + Spreadsheet, + Slides, + Epub, +} + +impl ChipKind { + /// `T3_PIERRE_ICONS.byFileExtension`'s document entries. + fn document(name: &str) -> Option { + Some(match extension(name, false)?.as_str() { + "pdf" => Self::Pdf, + "doc" | "docx" | "odt" | "rtf" => Self::Word, + "xls" | "xlsx" | "ods" | "csv" | "tsv" => Self::Spreadsheet, + "ppt" | "pptx" | "odp" => Self::Slides, + "epub" => Self::Epub, + _ => return None, + }) + } + + /// The chip of a reference found in the prompt text, which has no attachment record. + pub fn of_reference(kind: &str, name: &str) -> Self { + if kind == "image" { + Self::Image + } else { + Self::document(name).unwrap_or(Self::File) + } + } + + pub fn of(item: &Item) -> Self { + if item.kind == Kind::Image { + Self::Image + } else if video_mime(&item.name, &item.mime).is_some() { + Self::Video + } else { + Self::document(&item.name).unwrap_or(Self::File) + } + } + + /// The solved `--context-chip-accent` of the standard theme, as light and dark hex colors + /// (`contextChipVariables` over `getStandardPaintedThemeColors`). + pub fn accent(self, dark: bool) -> u32 { + let (light, night) = match self { + Self::File | Self::Image => (0x006a99, 0x0f96d3), + Self::Video => (0xa54800, 0xdd6e29), + Self::Pdf => (0xb33739, 0xe46561), + Self::Word => (0x0066ab, 0x3e92da), + Self::Spreadsheet => (0x11732f, 0x479e58), + Self::Slides => (0x9b4f00, 0xcd7833), + Self::Epub => (0x6b51bb, 0x937cea), + }; + if dark { night } else { light } + } + + /// The `--context-chip-invalid` of the standard theme. + pub fn invalid(dark: bool) -> u32 { + if dark { 0xff6467 } else { 0xc10007 } + } +} + /// The items in the order a send carries them: images, then files (`snapshotComposerSend`). fn ordered(items: &[Item]) -> Vec<&Item> { let (images, files): (Vec<_>, Vec<_>) = items.iter().partition(|item| item.kind == Kind::Image); @@ -485,7 +815,7 @@ pub fn context(items: &[Item]) -> Option { Some(json!({ "version": 1, "kind": item.kind.wire(), - "contextId": format!("{}_{}", item.kind.wire(), item.id), + "contextId": item.context_id(), "label": label(&item.name, item.kind), "attachmentId": item.attachment_id()?, "name": item.name, @@ -795,4 +1125,173 @@ mod tests { ); assert_eq!(title_seed(&[file]).as_deref(), Some("File: notes v2.txt")); } + + #[test] + fn hrefs_round_trip_and_reject_anything_else() { + let href = format_href("review-comment", "ctx_1"); + assert_eq!(href, "t3-context://v1/review-comment/ctx_1"); + assert_eq!(parse_href(&href), Some(("review-comment", "ctx_1"))); + for bad in [ + "t3-context://v2/image/ctx_1", + "t3-context://v1/image", + "t3-context://v1/image/ctx_1/extra", + "t3-context://v1/image/ctx_1?x=1", + "t3-context://v1/image/ctx_1#frag", + "t3-context://user@v1/image/ctx_1", + "t3-context://v1/Image/ctx_1", + "t3-context://v1/image/ctx 1", + "https://v1/image/ctx_1", + "t3-citation://v1/a/b/c", + ] { + assert_eq!(parse_href(bad), None, "{bad}"); + } + } + + #[test] + fn labels_are_normalized_and_kept_in_the_source() { + let text = "[](t3-context://v1/file/ctx_1)"; + assert_eq!(collect_references(text)[0].label, "file"); + let long = format!("[{}](t3-context://v1/file/ctx_1)", "x".repeat(300)); + assert_eq!(collect_references(&long)[0].label.len(), 200); + let pair = format!("[{}😀tail](t3-context://v1/file/ctx_1)", "x".repeat(199)); + assert_eq!(collect_references(&pair)[0].label, "x".repeat(199)); + } + + #[test] + fn references_are_collected_in_order_with_offsets() { + let text = "See ![a.png](t3-context://v1/image/ctx_1) then [T1](t3-context://v1/terminal/ctx_2) and again [a](t3-context://v1/image/ctx_1)."; + let found = collect_references(text); + let shape: Vec<_> = found + .iter() + .map(|r| { + ( + r.kind.as_str(), + r.context_id.as_str(), + r.label.as_str(), + r.image, + ) + }) + .collect(); + assert_eq!( + shape, + [ + ("image", "ctx_1", "a.png", true), + ("terminal", "ctx_2", "T1", false), + ("image", "ctx_1", "a", false), + ] + ); + assert_eq!( + &text[found[0].start..found[0].end], + "![a.png](t3-context://v1/image/ctx_1)" + ); + assert_eq!(strip_references(text), "See then and again ."); + } + + #[test] + fn references_are_written_like_the_web() { + assert_eq!( + format_reference("image", "image_1", "a.png"), + "![a.png](t3-context://v1/image/image_1)" + ); + let file = item("1", Kind::File, "my [draft].md", ready("a")); + assert_eq!( + file.reference(), + "[my draft .md](t3-context://v1/file/file_1)" + ); + let found = collect_references(&file.reference()); + assert_eq!(found[0].context_id, file.context_id()); + } + + #[test] + fn context_ids_are_scoped_and_folded() { + assert_eq!(kind_scoped_context_id("image", "x"), "image_x"); + assert_eq!(kind_scoped_context_id("image", "image_x"), "image_image_x"); + assert_eq!(to_context_id("file-comment-1700-1"), "file-comment-1700-1"); + let folded = to_context_id("pull-request-selection:src/a.ts:4-9"); + let (slug, digest) = folded.split_at(folded.len() - 16); + assert_eq!(slug, "pull-request-selection-src-a-ts-4-9-"); + assert!(digest.chars().all(|c| c.is_ascii_hexdigit())); + assert_eq!(to_context_id("pull-request-selection:src/a.ts:4-9"), folded); + assert_ne!(to_context_id("pull-request-selection:src/b.ts:4-9"), folded); + let bare = to_context_id("::"); + assert!(bare.starts_with("ctx-") && bare.len() == 20); + let shared = format!("pull-request-finding:{}", "a".repeat(60)); + let (first, second) = ( + to_context_id(&format!("{shared}:1")), + to_context_id(&format!("{shared}:2")), + ); + assert_ne!(first, second); + assert!(first.len() <= 128); + } + + #[test] + fn attaching_pads_the_references_with_single_spaces() { + let at_start = insertion(None, None); + assert!(!at_start.leading_space && !at_start.reuse_next_space); + assert!(insertion(Some('a'), None).leading_space); + assert!(!insertion(Some(' '), None).leading_space); + assert!(!insertion(Some('\n'), None).leading_space); + assert!(insertion(Some('a'), Some(' ')).reuse_next_space); + assert!(!insertion(Some('a'), Some('\n')).reuse_next_space); + } + + #[test] + fn images_get_chips_only_beside_other_text() { + let image = "![a.png](t3-context://v1/image/image_1)"; + assert!(!images_get_chips("", false)); + assert!(!images_get_chips(&format!(" {image} "), false)); + assert!(images_get_chips(&format!("{image} what is this?"), false)); + assert!(images_get_chips("", true)); + } + + #[test] + fn long_names_are_truncated_in_the_middle() { + assert_eq!(middle_truncate_name("notes.md", 36), "notes.md"); + let name = "a-very-long-attachment-name-that-keeps-going-and-going.final.pdf"; + let short = middle_truncate_name(name, 36); + assert_eq!(short.chars().count(), 36); + assert_eq!(short, "a-very-long-attachmen…oing.final.pdf"); + assert_eq!(middle_truncate_name("abc", 0), ""); + assert_eq!(middle_truncate_name("abc", 1), "…"); + assert_eq!(middle_truncate_name("abcdefghij", 4), "a…ij"); + } + + #[test] + fn chips_take_the_kind_of_their_file() { + let kind = |name: &str, mime: &str, kind: Kind| { + let mut file = item("1", kind, name, ready("a")); + file.mime = mime.to_owned(); + ChipKind::of(&file) + }; + assert_eq!(kind("a.PDF", "application/pdf", Kind::File), ChipKind::Pdf); + assert_eq!(kind("a.docx", "", Kind::File), ChipKind::Word); + assert_eq!(kind("a.csv", "text/csv", Kind::File), ChipKind::Spreadsheet); + assert_eq!(kind("a.pptx", "", Kind::File), ChipKind::Slides); + assert_eq!(kind("a.epub", "", Kind::File), ChipKind::Epub); + assert_eq!(kind("a.mp4", "video/mp4", Kind::File), ChipKind::Video); + assert_eq!(kind("a.mov", "", Kind::File), ChipKind::Video); + assert_eq!(kind("a.rs", "text/x-rust", Kind::File), ChipKind::File); + assert_eq!(kind(".pdf", "", Kind::File), ChipKind::File); + assert_eq!(kind("a.png", "image/png", Kind::Image), ChipKind::Image); + } + + #[test] + fn removing_a_reference_takes_one_neighbouring_space() { + let apply = |text: &str, id: &str| { + let mut text = text.to_owned(); + for range in removal_ranges(&text.clone(), id) { + text.replace_range(range, ""); + } + text + }; + let a = "[a](t3-context://v1/file/file_1)"; + let b = "[b](t3-context://v1/file/file_2)"; + assert_eq!(apply(&format!("see {a} now"), "file_1"), "see now"); + assert_eq!(apply(&format!("see {a}"), "file_1"), "see"); + assert_eq!(apply(&format!("{a} {b} "), "file_1"), format!("{b} ")); + assert_eq!(apply(&format!("{a} {b} "), "file_2"), a.to_string()); + assert_eq!(apply(&format!("{a} {a}"), "file_1"), ""); + assert_eq!(apply("plain", "file_1"), "plain"); + assert!(removal_ranges("plain", "file_1").is_empty()); + } } diff --git a/apps/desktop-gpui/src/chip-icons/epub.svg b/apps/desktop-gpui/src/chip-icons/epub.svg new file mode 100644 index 000000000000..db4f30160e90 --- /dev/null +++ b/apps/desktop-gpui/src/chip-icons/epub.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/chip-icons/image.svg b/apps/desktop-gpui/src/chip-icons/image.svg new file mode 100644 index 000000000000..135ea2223d54 --- /dev/null +++ b/apps/desktop-gpui/src/chip-icons/image.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/chip-icons/pdf.svg b/apps/desktop-gpui/src/chip-icons/pdf.svg new file mode 100644 index 000000000000..cf25840c37c5 --- /dev/null +++ b/apps/desktop-gpui/src/chip-icons/pdf.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/chip-icons/slides.svg b/apps/desktop-gpui/src/chip-icons/slides.svg new file mode 100644 index 000000000000..bc2c814816c4 --- /dev/null +++ b/apps/desktop-gpui/src/chip-icons/slides.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/chip-icons/spreadsheet.svg b/apps/desktop-gpui/src/chip-icons/spreadsheet.svg new file mode 100644 index 000000000000..53923de42a30 --- /dev/null +++ b/apps/desktop-gpui/src/chip-icons/spreadsheet.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/chip-icons/video.svg b/apps/desktop-gpui/src/chip-icons/video.svg new file mode 100644 index 000000000000..5ad3fdb9a226 --- /dev/null +++ b/apps/desktop-gpui/src/chip-icons/video.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/chip-icons/word.svg b/apps/desktop-gpui/src/chip-icons/word.svg new file mode 100644 index 000000000000..c3c6768f23f8 --- /dev/null +++ b/apps/desktop-gpui/src/chip-icons/word.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index fba13e075932..f0c9a73433e9 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -1,4 +1,4 @@ -use crate::assets::{MESSAGE_SQUARE_DASHED, STAGE_DEV, STAGE_DEV_BUTTON, provider_icon}; +use crate::assets::{MESSAGE_SQUARE_DASHED, STAGE_DEV, STAGE_DEV_BUTTON, chip_icon, provider_icon}; use crate::attachments::{self, Upload}; use crate::branch_picker::{self, BranchPicker}; use crate::connection::{Action, ActionSender, Call, command_id, new_project_id}; @@ -35,7 +35,9 @@ use gpui_component::{ ActiveTheme, Icon, IconName, Selectable, Sizable, TitleBar, button::{Button, ButtonVariants}, h_flex, - input::{Input, InputEvent, InputState, Textarea, TextareaState}, + input::{ + InlineToken, InlineTokenContext, Input, InputEvent, InputState, Textarea, TextareaState, + }, kbd::Kbd, popover::Popover, tooltip::Tooltip, @@ -673,17 +675,17 @@ impl MainView { } /// The attach button: the native picker stands in for the web's file input. - fn attach_files(&mut self, cx: &mut Context) { + fn attach_files(&mut self, window: &mut Window, cx: &mut Context) { let picked = cx.prompt_for_paths(PathPromptOptions { files: true, directories: false, multiple: true, prompt: Some("Attach files".into()), }); - cx.spawn(async move |this, cx| { + cx.spawn_in(window, async move |this, cx| { let result = picked.await; - this.update(cx, |this, cx| match result { - Ok(Ok(Some(paths))) => this.add_attachments(paths, cx), + this.update_in(cx, |this, window, cx| match result { + Ok(Ok(Some(paths))) => this.add_attachments(paths, window, cx), Ok(Err(error)) => this.error_toast("Failed to attach files", error.to_string(), cx), _ => {} }) @@ -693,7 +695,12 @@ impl MainView { } /// The web's `addComposerAttachments`: validate, stage and start uploading each file. - fn add_attachments(&mut self, paths: Vec, cx: &mut Context) { + fn add_attachments( + &mut self, + paths: Vec, + window: &mut Window, + cx: &mut Context, + ) { let picked: Vec = paths .into_iter() .map(|path| attachments::Picked { @@ -714,9 +721,17 @@ impl MainView { if let Some(message) = error { self.message_toast(message, cx); } + let no_project = self.selected_thread.is_none() + && self + .shell() + .and_then(|shell| active_project(self.selected_project.as_deref(), shell)) + .is_none(); + let images_chip = + attachments::images_get_chips(&self.composer.read(cx).value(), no_project); + let mut added = Vec::new(); for file in accepted { let id = new_project_id(); - self.attachments.push(attachments::Item { + let item = attachments::Item { id: id.clone(), path: file.path, kind: file.kind, @@ -724,12 +739,75 @@ impl MainView { mime: file.mime, size: file.size, upload: Upload::Uploading { progress: 0. }, - }); + }; + added.push(item.clone()); + self.attachments.push(item); self.start_upload(&id, cx); } + added.sort_by_key(|item| item.kind == attachments::Kind::Image); + added.retain(|item| item.kind == attachments::Kind::File || images_chip); + self.insert_references(&added, window, cx); cx.notify(); } + /// The web's `insertAttachmentReferences`: one chip per item at the cursor, each followed by a + /// space, with a leading space unless the cursor follows whitespace. + fn insert_references( + &mut self, + items: &[attachments::Item], + window: &mut Window, + cx: &mut Context, + ) { + if items.is_empty() { + return; + } + self.composer.update(cx, |composer, cx| { + let text = composer.value(); + let cursor = composer.cursor().min(text.len()); + let plan = attachments::insertion( + text[..cursor].chars().next_back(), + text[cursor..].chars().next(), + ); + composer.set_selected_range(cursor..cursor, cx); + if plan.leading_space { + composer.insert(" ", window, cx); + } + for (index, item) in items.iter().enumerate() { + if index > 0 { + composer.insert(" ", window, cx); + } + let reference = item.reference(); + let token = InlineToken::new(item.context_id(), reference.clone()); + if composer.replace_with_token(token, window, cx).is_err() { + composer.insert(reference, window, cx); + } + } + if plan.reuse_next_space { + let end = composer.cursor() + 1; + composer.set_selected_range(end..end, cx); + } else { + composer.insert(" ", window, cx); + } + composer.focus(window, cx); + }); + } + + /// The web's `removeInlineContextReference`. + fn remove_inline_reference( + &mut self, + context_id: &str, + window: &mut Window, + cx: &mut Context, + ) { + self.composer.update(cx, |composer, cx| { + let text = composer.value(); + for range in attachments::removal_ranges(&text, context_id) { + composer.set_selected_range(range, cx); + composer.replace("", window, cx); + } + }); + } + /// Asks the server for an upload URL; `on_attachment_call` posts the file once it answers. fn start_upload(&mut self, id: &str, cx: &mut Context) { let Some(item) = self.attachments.iter_mut().find(|item| item.id == id) else { @@ -872,11 +950,12 @@ impl MainView { self.submit(cx); } - fn remove_attachment(&mut self, id: &str, cx: &mut Context) { + fn remove_attachment(&mut self, id: &str, window: &mut Window, cx: &mut Context) { let Some(index) = self.attachments.iter().position(|item| item.id == id) else { return; }; let item = self.attachments.remove(index); + self.remove_inline_reference(&item.context_id(), window, cx); if let Some(attachment_id) = item.attachment_id() { self.delete_attachment(attachment_id); } @@ -893,7 +972,19 @@ impl MainView { /// The web's `ComposerAttachmentMediaRow` and `ComposerAttachmentFileList`. fn attachment_strip(&self, cx: &mut Context) -> Option { - if self.attachments.is_empty() { + let inline: HashSet = + attachments::collect_references(&self.composer.read(cx).value()) + .into_iter() + .map(|reference| reference.context_id) + .collect(); + let shown: Vec<&attachments::Item> = self + .attachments + .iter() + .filter(|item| { + item.kind == attachments::Kind::Image || !inline.contains(&item.context_id()) + }) + .collect(); + if shown.is_empty() { return None; } let theme = cx.theme(); @@ -910,7 +1001,7 @@ impl MainView { }; let mut tiles = Vec::new(); let mut rows = Vec::new(); - for item in &self.attachments { + for item in shown { let remove_id = item.id.clone(); let retry_id = item.id.clone(); let progress = match &item.upload { @@ -932,7 +1023,9 @@ impl MainView { ghost }, ) - .on_click(cx.listener(move |this, _, _, cx| this.remove_attachment(&remove_id, cx))); + .on_click(cx.listener(move |this, _, window, cx| { + this.remove_attachment(&remove_id, window, cx) + })); let retry = failed.map(|reason| { attachment_button( format!("attachment-retry-{}", item.id), @@ -4016,6 +4109,7 @@ impl MainView { &self.attachments, self.sending.is_some(), ); + let chips = chip_infos(&self.attachments); v_flex() .w(px(720.)) .max_w_full() @@ -4038,7 +4132,8 @@ impl MainView { .mx(px(-10.)) .my(px(-8.)) // The web's `leading-relaxed` at 14px. - .line_height(px(22.75)), + .line_height(px(22.75)) + .token(move |token, _, cx| composer_chip(token, &chips, cx)), ), ) .child( @@ -4054,7 +4149,7 @@ impl MainView { true, cx, ) - .on_click(cx.listener(|this, _, _, cx| this.attach_files(cx))), + .on_click(cx.listener(|this, _, window, cx| this.attach_files(window, cx))), ) .child( h_flex().gap_2().children(model_picker).child( @@ -4631,6 +4726,123 @@ fn scratch_entry(entries: &[ProjectEntry]) -> Option<&ProjectEntry> { } /// The web's `TooltipPopup` sizing: `text-xs`, `px-2 py-1`, `rounded-md`. +/// What a chip shows beyond its reference text. +struct ChipInfo { + name: String, + size: String, + kind: attachments::ChipKind, + progress: Option, + failed: Option, +} + +fn chip_infos(items: &[attachments::Item]) -> Rc> { + Rc::new( + items + .iter() + .map(|item| { + let info = ChipInfo { + name: item.name.clone(), + size: attachments::format_size(item.size), + kind: attachments::ChipKind::of(item), + progress: match &item.upload { + Upload::Uploading { progress } => { + Some(attachments::format_progress(*progress)) + } + _ => None, + }, + failed: match &item.upload { + Upload::Failed { reason } => Some(reason.clone()), + _ => None, + }, + }; + (item.context_id(), info) + }) + .collect(), + ) +} + +/// The web's `FileChipContent` / `ImageChipButton` as an inline chip (`ContextChip`). +fn composer_chip( + token: &InlineTokenContext, + chips: &HashMap, + cx: &App, +) -> AnyElement { + let theme = cx.theme(); + let dark = theme.is_dark(); + let info = chips.get(token.token().id().as_ref()); + let reference = attachments::collect_references(token.token().text()) + .into_iter() + .next(); + let name = info + .map(|info| info.name.clone()) + .or_else(|| reference.as_ref().map(|reference| reference.label.clone())) + .unwrap_or_default(); + let kind = info.map(|info| info.kind).unwrap_or_else(|| { + let wire = reference + .as_ref() + .map_or("file", |reference| &reference.kind); + attachments::ChipKind::of_reference(wire, &name) + }); + let failed = info.and_then(|info| info.failed.clone()); + let color: Hsla = rgb(if failed.is_some() { + attachments::ChipKind::invalid(dark) + } else { + kind.accent(dark) + }) + .into(); + let size = info.map(|info| info.size.clone()); + let mut hint = name.clone(); + if let Some(size) = &size { + hint = format!("{hint}\n{size}"); + } + if let Some(reason) = &failed { + hint = format!("{hint}\n{reason}"); + } else if let Some(progress) = info.and_then(|info| info.progress.as_ref()) { + hint = format!("{hint}\nUploading {progress}"); + } + let icon = match chip_icon(kind) { + Some(path) => Icon::empty().path(path), + None => Icon::new(Icons::File), + }; + h_flex() + .id(("composer-chip", token.range().start)) + .group("composer-chip") + .max_w_full() + .items_center() + .gap(px(2.8)) + .rounded(px(4.)) + .text_size(px(14.)) + .font_weight(FontWeight::MEDIUM) + .text_color(color) + .when(token.is_selected(), |chip| chip.bg(theme.selection)) + .tooltip(tooltip(hint)) + .child(icon.size(px(14.)).flex_shrink_0().text_color(color)) + .child( + h_flex() + .min_w_0() + .items_baseline() + .gap(px(4.62)) + .group_hover("composer-chip", |label| label.underline()) + .child( + div() + .min_w_0() + .max_w(px(288.)) + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .child(attachments::middle_truncate_name(&name, 36)), + ) + .children(size.map(|size| { + div() + .flex_shrink_0() + .text_size(px(10.)) + .line_height(px(14.)) + .child(size) + })), + ) + .into_any_element() +} + fn tooltip( text: impl Into, ) -> impl Fn(&mut Window, &mut App) -> gpui::AnyView + 'static { From 53c38c289444d47bc4e254e4f1fdce0f8622a902 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 8 Nov 2026 02:54:47 -0500 Subject: [PATCH 57/82] feat(desktop-gpui): usage modal with cost, tokens and breakdown The sidebar Usage button opened nothing. It and cmd-u now open the web's Usage modal: window and metric tabs with saved preferences, refresh, a scrolling body with provider totals, the daily chart, totals, tokens by type and the model/day breakdown, fed by server.getUsageSummary. --- apps/desktop-gpui/src/connection.rs | 14 +- apps/desktop-gpui/src/main.rs | 4 + apps/desktop-gpui/src/rpc.rs | 10 + apps/desktop-gpui/src/ui.rs | 40 +- apps/desktop-gpui/src/ui/usage_dialog.rs | 1880 +++++++++++++++++++ apps/desktop-gpui/src/usage_data.rs | 2095 ++++++++++++++++++++++ 6 files changed, 4031 insertions(+), 12 deletions(-) create mode 100644 apps/desktop-gpui/src/ui/usage_dialog.rs create mode 100644 apps/desktop-gpui/src/usage_data.rs diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index c0953f469d80..89f89ba2ab56 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -8,10 +8,10 @@ use crate::providers::Catalog; use crate::rpc::{ AttachmentsCreateUploadUrl, AttachmentsDelete, Event, GitRunStackedAction, OrchestrationDispatchCommand, OrchestrationLaunchThread, PING_FRAME, ProjectsMutate, - ReviewGetDiffPreview, ServerGetConfig, ServerUpdateSettings, Session, ShellOpenInEditor, - SubscribeShell, SubscribeThread, SubscribeVcsStatus, TerminalAttach, TerminalClose, - TerminalResize, TerminalWrite, VcsCreateRef, VcsListRefs, VcsPull, VcsRefreshStatus, - VcsSwitchRef, + ReviewGetDiffPreview, ServerGetConfig, ServerGetUsageSummary, ServerRefreshUsageRates, + ServerUpdateSettings, Session, ShellOpenInEditor, SubscribeShell, SubscribeThread, + SubscribeVcsStatus, TerminalAttach, TerminalClose, TerminalResize, TerminalWrite, VcsCreateRef, + VcsListRefs, VcsPull, VcsRefreshStatus, VcsSwitchRef, }; use crate::shell::ShellState; use crate::status::{CallEvent, Status, StatusSender, publish}; @@ -125,6 +125,8 @@ pub enum Call { RunStackedAction, CreateUploadUrl, DeleteAttachment, + GetUsageSummary, + RefreshUsageRates, } pub type ActionSender = async_channel::Sender; @@ -165,6 +167,8 @@ impl Call { Self::RunStackedAction => "run the action", Self::CreateUploadUrl => "prepare the upload", Self::DeleteAttachment => "delete the attachment", + Self::GetUsageSummary => "load usage", + Self::RefreshUsageRates => "refresh usage rates", } } @@ -185,6 +189,8 @@ impl Call { Self::RunStackedAction => session.request::(payload), Self::CreateUploadUrl => session.request::(payload), Self::DeleteAttachment => session.request::(payload), + Self::GetUsageSummary => session.request::(payload), + Self::RefreshUsageRates => session.request::(payload), } } } diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index da7dfb5018cc..4af7fdb55013 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -35,6 +35,7 @@ mod toast; mod ui; mod upload; mod usage; +mod usage_data; mod wire; use assets::AppAssets; @@ -104,8 +105,11 @@ fn main() { gpui_component::init(cx); Theme::change(ThemeMode::Dark, None, cx); // The web's default `sidebar.toggle` and `navigation.*` bindings. + cx.bind_keys(ui::usage_bindings()); cx.bind_keys([ KeyBinding::new("cmd-b", ui::ToggleSidebar, None), + KeyBinding::new("cmd-u", ui::OpenUsage, None), + KeyBinding::new("cmd-u", gpui::NoAction, Some("Terminal")), KeyBinding::new("escape", ui::DismissSidebar, Some(ui::OVERLAY_CONTEXT)), KeyBinding::new("shift-cmd-a", ui::OpenPermissions, None), KeyBinding::new("shift-cmd-b", ui::ToggleDetails, None), diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs index 72d1cf33ef16..bed9c1f13e12 100644 --- a/apps/desktop-gpui/src/rpc.rs +++ b/apps/desktop-gpui/src/rpc.rs @@ -212,6 +212,16 @@ untyped_method!( VcsRefreshStatus, "vcs.refreshStatus" ); +untyped_method!( + /// `server.getUsageSummary`: token and cost buckets for a day window. + ServerGetUsageSummary, + "server.getUsageSummary" +); +untyped_method!( + /// `server.refreshUsageRates`: refetches the price table. + ServerRefreshUsageRates, + "server.refreshUsageRates" +); /// `git.runStackedAction`, a stream of progress events ending with the action's result. pub struct GitRunStackedAction; diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index f0c9a73433e9..3e6afe7fd04e 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -57,6 +57,9 @@ const TERMINAL_SIDEBAR_WIDTH: f32 = 144.; const TERMINAL_HANDLE_HEIGHT: f32 = 8.; mod model_popover; mod right_panel; +mod usage_dialog; + +pub use usage_dialog::bindings as usage_bindings; /// The web's `THREAD_SIDEBAR_DEFAULT_WIDTH`. const SIDEBAR_DEFAULT_WIDTH: f32 = 256.; @@ -92,7 +95,8 @@ actions!( ToggleRightPanel, OpenFavoriteEditor, NewThread, - NewScratchThread + NewScratchThread, + OpenUsage ] ); @@ -269,6 +273,7 @@ pub struct MainView { terminal_attached: Vec, card: Entity, toasts: Entity, + usage: Entity, /// The slow requests toast; kept after the user dismisses it, like the web coordinator. slow_toast: Option, /// The main area's width at the last render, for fitting the details card. @@ -434,6 +439,7 @@ impl MainView { terminal_attached: Vec::new(), card, toasts: cx.new(|cx| ToastHost::new(TOPBAR_HEIGHT, window, cx)), + usage: cx.new(|cx| usage_dialog::UsageDialog::new(actions.clone(), cx)), slow_toast: None, main_width: Cell::new(0.), sending: None, @@ -532,6 +538,11 @@ impl MainView { return cx.notify(); } if let Status::Call { token, event } = status { + if token >= usage_dialog::TOKEN_BASE { + self.usage + .update(cx, |usage, cx| usage.on_call(token, event, cx)); + return; + } if token >= ATTACH_TOKEN { self.on_attachment_call(token, event, cx); return; @@ -554,6 +565,14 @@ impl MainView { self.vcs.clear(); self.diffs_requested.clear(); } + let access = match &status { + Status::NotConnected => usage_dialog::Access::Unavailable, + Status::Starting | Status::Connecting => usage_dialog::Access::Pending, + Status::Failed(_) => usage_dialog::Access::Offline, + _ => usage_dialog::Access::Ready, + }; + self.usage + .update(cx, |usage, cx| usage.set_access(access, cx)); self.status = status; // Thread activity reorders recency on every delta, so the default must not follow it. if self.selected_project.is_none() { @@ -2277,7 +2296,7 @@ impl MainView { } /// The web's `SidebarChromeFooter`: utility buttons left, the update control right. - fn sidebar_footer(&self, cx: &App) -> impl IntoElement { + fn sidebar_footer(&self, cx: &mut Context) -> impl IntoElement { h_flex() .pt_1() .gap_px() @@ -2293,12 +2312,13 @@ impl MainView { "Pull requests", cx, )) - .child(footer_button( - "usage", - Icon::new(Icons::ChartNoAxesColumn), - "Usage", - cx, - )) + .child( + footer_button("usage", Icon::new(Icons::ChartNoAxesColumn), "Usage", cx).on_click( + cx.listener(|this, _, window, cx| { + this.usage.update(cx, |usage, cx| usage.open(window, cx)); + }), + ), + ) .child(div().flex_1()) .child(update_button(cx)) } @@ -6108,6 +6128,9 @@ impl Render for MainView { ) .on_action(cx.listener(|this, _: &OpenPermissions, _, cx| this.open_permissions(cx))) .on_action(cx.listener(|this, _: &ToggleDetails, _, cx| this.toggle_details(cx))) + .on_action(cx.listener(|this, _: &OpenUsage, window, cx| { + this.usage.update(cx, |usage, cx| usage.open(window, cx)); + })) .on_action(cx.listener(|this, _: &ToggleRightPanel, _, cx| this.toggle_right_panel(cx))) .on_action( cx.listener(|this, _: &ToggleTerminal, window, cx| { @@ -6158,6 +6181,7 @@ impl Render for MainView { .when(overlay, |root| { root.child(self.sidebar_drawer(overlay_width(width), window, cx)) }) + .child(self.usage.clone()) .child(self.toasts.clone()) } } diff --git a/apps/desktop-gpui/src/ui/usage_dialog.rs b/apps/desktop-gpui/src/ui/usage_dialog.rs new file mode 100644 index 000000000000..cfd1019dceef --- /dev/null +++ b/apps/desktop-gpui/src/ui/usage_dialog.rs @@ -0,0 +1,1880 @@ +//! The web's Usage page (`UsagePage.tsx`) as the `/usage` route modal (`AppRouteModal.tsx`). + +use super::*; +use crate::usage_data::{ + self as data, Merged, Metric, Prefs, Provider, Summary, Tint, Window as Span, Zone, +}; +use gpui::{ + ClickEvent, ElementId, FocusHandle, KeyBinding, PathBuilder, ScrollHandle, Size, fill, point, + radians, relative, size, +}; +use serde_json::{Value, json}; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Call tokens, above the attachment range. +pub const TOKEN_BASE: u64 = 1 << 43; +/// `usagePageOpen` in the web's keybinding `when` clauses. +const CONTEXT: &str = "UsagePage"; +/// A summary older than this is fetched again when the page opens. +const STALE_MS: i64 = 60_000; +/// `figureClass`, `opacity-40`. +const DIMMED: f32 = 0.4; +/// The chart's `h-56`. +const PLOT_HEIGHT: f32 = 224.; +/// `WorkspacePageContainer` `max-w-5xl`. +const CONTENT_MAX: f32 = 1024.; + +actions!( + usage, + [ + CloseUsage, + ShowCost, + ShowTokens, + ShowLimits, + PastDay, + PastWeek, + PastMonth, + PastQuarter + ] +); + +/// `usage.*` entries of `DEFAULT_KEYBINDINGS` (keybindings.ts:232-238), plus Escape for the modal. +pub fn bindings() -> Vec { + let context = Some(CONTEXT); + vec![ + KeyBinding::new("escape", CloseUsage, context), + KeyBinding::new("c", ShowCost, context), + KeyBinding::new("t", ShowTokens, context), + KeyBinding::new("l", ShowLimits, context), + KeyBinding::new("shift-cmd-1", PastDay, context), + KeyBinding::new("shift-cmd-2", PastWeek, context), + KeyBinding::new("shift-cmd-3", PastMonth, context), + KeyBinding::new("shift-cmd-4", PastQuarter, context), + ] +} + +/// What the connection says about reading usage (`resolveUsageAccess`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Access { + Unavailable, + Pending, + Offline, + Ready, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Stage { + Idle, + First, + Awaiting, +} + +enum Pending { + Query { + generation: u64, + awaiting: bool, + span: Span, + }, + Rates { + generation: u64, + }, +} + +struct Shown { + span: Span, + summary: Summary, + merged: Merged, + fetched_at: i64, +} + +/// `usageLoading`: which providers' figures are still settling. +enum Dim { + None, + All, + Providers(Vec), +} + +impl Dim { + fn provider(&self, provider: Provider) -> bool { + match self { + Self::None => false, + Self::All => true, + Self::Providers(list) => list.contains(&provider), + } + } + + fn partial(&self) -> bool { + !matches!(self, Self::None) + } +} + +pub struct UsageDialog { + actions: ActionSender, + focus: FocusHandle, + previous_focus: Option, + open: bool, + prefs: Prefs, + zone: Zone, + span: Span, + access: Access, + shown: Option, + failed: bool, + manual: bool, + stage: Stage, + generation: u64, + breakdown_time: bool, + hover: Option<(usize, gpui::Point)>, + plot: Rc>>, + tip: Rc>>, + scroll: ScrollHandle, + next_token: u64, + pending: HashMap, +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_millis() as i64) +} + +impl UsageDialog { + pub fn new(actions: ActionSender, cx: &mut Context) -> Self { + let prefs = Prefs::load(); + let zone = Zone::detect(); + let span = data::make_window(prefs.window_days, now_ms(), prefs.window_days == 1, &zone); + Self { + actions, + focus: cx.focus_handle(), + previous_focus: None, + open: false, + prefs, + zone, + span, + access: Access::Unavailable, + shown: None, + failed: false, + manual: false, + stage: Stage::Idle, + generation: 0, + breakdown_time: false, + hover: None, + plot: Rc::default(), + tip: Rc::default(), + scroll: ScrollHandle::new(), + next_token: TOKEN_BASE, + pending: HashMap::new(), + } + } + + pub fn open(&mut self, window: &mut Window, cx: &mut Context) { + if self.open { + return; + } + self.open = true; + self.previous_focus = window.focused(cx); + self.span = self.make_span(); + self.hover = None; + self.breakdown_time = false; + window.focus(&self.focus, cx); + self.ensure_loaded(); + cx.notify(); + } + + pub fn close(&mut self, window: &mut Window, cx: &mut Context) { + if !self.open { + return; + } + self.open = false; + if let Some(previous) = self.previous_focus.take() { + window.focus(&previous, cx); + } + cx.notify(); + } + + pub fn set_access(&mut self, access: Access, cx: &mut Context) { + if self.access == access { + return; + } + self.access = access; + if self.open { + self.ensure_loaded(); + } + cx.notify(); + } + + fn make_span(&self) -> Span { + data::make_window( + self.prefs.window_days, + now_ms(), + self.prefs.window_days == 1, + &self.zone, + ) + } + + fn can_read(&self) -> bool { + self.access == Access::Ready + } + + fn call(&mut self, call: Call, payload: Value, pending: Pending) { + self.next_token += 1; + let token = self.next_token; + self.pending.insert(token, pending); + let action = Action::Call { + token, + call, + payload, + }; + if self.actions.try_send(action).is_err() { + self.pending.remove(&token); + eprintln!("[usage] could not send the request"); + } + } + + /// Fetches when nothing is shown, the shown window is another one, or the data is old. + fn ensure_loaded(&mut self) { + if !self.can_read() || self.prefs.metric == Metric::Limits || self.stage != Stage::Idle { + return; + } + let fresh = self + .shown + .as_ref() + .is_some_and(|shown| shown.span == self.span && now_ms() - shown.fetched_at < STALE_MS); + if !fresh && !self.manual { + self.start_query(); + } + } + + fn start_query(&mut self) { + self.generation += 1; + self.stage = Stage::First; + self.failed = false; + self.query(false); + } + + fn query(&mut self, awaiting: bool) { + let pending = Pending::Query { + generation: self.generation, + awaiting, + span: self.span.clone(), + }; + self.call(Call::GetUsageSummary, self.span.payload(awaiting), pending); + } + + pub fn on_call(&mut self, token: u64, event: CallEvent, cx: &mut Context) { + if matches!(event, CallEvent::Progress(_)) { + return; + } + let Some(pending) = self.pending.remove(&token) else { + return; + }; + match pending { + Pending::Rates { generation } => { + if generation == self.generation { + self.start_query(); + } + } + Pending::Query { + generation, + awaiting, + span, + } => { + if generation != self.generation { + return; + } + let summary = match event { + CallEvent::Done(value) => Summary::decode(&value), + _ => None, + }; + match summary { + Some(summary) => { + let merged = data::merge(&summary); + let again = !awaiting && summary.refreshing(); + self.shown = Some(Shown { + span, + merged, + summary, + fetched_at: now_ms(), + }); + self.failed = false; + if again { + self.stage = Stage::Awaiting; + self.query(true); + } else { + self.stage = Stage::Idle; + self.manual = false; + } + } + None => { + if !awaiting { + self.failed = true; + } + self.stage = Stage::Idle; + self.manual = false; + } + } + } + } + cx.notify(); + } + + fn select_window(&mut self, days: u32, cx: &mut Context) { + self.prefs.window_days = days; + self.prefs.save(); + self.span = self.make_span(); + self.hover = None; + self.stage = Stage::Idle; + self.ensure_loaded(); + cx.notify(); + } + + fn select_metric(&mut self, metric: Metric, cx: &mut Context) { + self.prefs.metric = metric; + self.prefs.save(); + self.hover = None; + self.ensure_loaded(); + cx.notify(); + } + + fn refresh(&mut self, cx: &mut Context) { + if self.manual || !self.can_read() || self.prefs.metric == Metric::Limits { + return; + } + self.manual = true; + self.span = self.make_span(); + self.generation += 1; + self.stage = Stage::Idle; + let pending = Pending::Rates { + generation: self.generation, + }; + self.call(Call::RefreshUsageRates, json!({}), pending); + cx.notify(); + } + + /// `onUsageKeyDown`: the period shortcuts do nothing on the limits tab. + fn shortcut_window(&mut self, days: u32, cx: &mut Context) { + if self.prefs.metric != Metric::Limits { + self.select_window(days, cx); + } + } + + fn dim(&self) -> Dim { + let Some(shown) = &self.shown else { + return Dim::None; + }; + if self.manual || shown.span != self.span { + return Dim::All; + } + match self.stage { + Stage::Idle => Dim::None, + Stage::First => Dim::All, + Stage::Awaiting => { + let mut providers: Vec = Vec::new(); + for source in shown.summary.sources.iter().filter(|s| s.refreshing) { + if !providers.contains(&source.provider) { + providers.push(source.provider); + } + } + if providers.is_empty() { + Dim::All + } else { + Dim::Providers(providers) + } + } + } + } +} + +fn tint(provider: Provider, cx: &App) -> Hsla { + let theme = cx.theme(); + match provider.tint() { + Tint::Fixed(hex) => rgb(hex).into(), + Tint::Contrast => theme.foreground, + Tint::ContrastSoft => theme.background.blend(theme.foreground.opacity(0.72)), + } +} + +fn mark(provider: Provider, size: f32, cx: &App) -> gpui::Div { + let mark = Mark { + driver: provider.driver_kind().to_owned(), + display_name: provider.label().to_owned(), + badge: None, + }; + provider_mark_at(&mark, size, size, 1., cx) +} + +fn dimmed(loading: bool) -> f32 { + if loading { DIMMED } else { 1. } +} + +/// `ui/toggle.tsx` segmented item. +fn toggle( + id: impl Into, + label: &'static str, + title: String, + selected: bool, + on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, +) -> gpui::Stateful { + let theme = cx.theme(); + let dark = theme.is_dark(); + let (hover_bg, pressed_bg) = if dark { + (theme.input.opacity(0.32), theme.input.opacity(0.72)) + } else { + (theme.background.opacity(0.55), theme.background) + }; + let (muted, foreground) = (theme.muted_foreground, theme.foreground); + div() + .id(id) + .h(px(24.)) + .px(px(10.)) + .flex() + .items_center() + .rounded(px(6.)) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .cursor_pointer() + .tooltip(tooltip(title)) + .when(selected, |item| item.bg(pressed_bg).text_color(foreground)) + .when(!selected, |item| { + item.text_color(muted) + .hover(move |style| style.bg(hover_bg).text_color(foreground)) + }) + .on_click(on_click) + .child(label) +} + +fn toggle_group(cx: &App) -> gpui::Div { + h_flex() + .flex_shrink_0() + .gap(px(2.)) + .p(px(2.)) + .rounded(px(8.)) + .bg(cx.theme().input.opacity(0.4)) +} + +/// The breadcrumb's picker triggers: label plus a `size-3.5` chevron. +fn filter_label(label: &'static str, alert: bool, cx: &App) -> gpui::Div { + let theme = cx.theme(); + h_flex() + .gap_1() + .items_center() + .text_color(theme.foreground) + .when(alert, |row| { + row.child( + Icon::new(Icons::CircleAlert) + .size(px(14.)) + .text_color(theme.warning), + ) + }) + .child(label) + .child( + Icon::new(Icons::ChevronDown) + .size(px(14.)) + .text_color(theme.muted_foreground), + ) +} + +/// `Button` `ghost` `icon-sm` without a tooltip, which the web's refresh button does not have. +fn plain_icon_button( + id: &'static str, + icon: impl IntoElement, + enabled: bool, + cx: &App, +) -> gpui::Stateful { + let theme = cx.theme(); + let (idle, hover_bg, hover_fg) = ( + theme.muted_foreground, + theme.foreground.opacity(0.06), + theme.foreground, + ); + div() + .id(id) + .flex_shrink_0() + .size(px(28.)) + .flex() + .items_center() + .justify_center() + .rounded(px(6.)) + .text_color(idle) + .when(enabled, |button| { + button + .cursor_pointer() + .hover(move |style| style.bg(hover_bg).text_color(hover_fg)) + }) + .when(!enabled, |button| button.opacity(0.64)) + .child(icon) +} + +fn skeleton_block(width: Option, height: f32, pill: bool, cx: &App) -> gpui::Div { + div() + .h(px(height)) + .map(|block| match width { + Some(width) => block.w(px(width)).flex_shrink_0(), + None => block.w_full(), + }) + .bg(cx.theme().muted_foreground.opacity(0.15)) + .rounded(px(if pill { 999. } else { 4. })) +} + +impl Render for UsageDialog { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.open { + return gpui::Empty.into_any_element(); + } + let theme = cx.theme(); + let viewport = window.viewport_size(); + let (vw, vh) = (f32::from(viewport.width), f32::from(viewport.height)); + let pad = if vw >= 1024. { + 40. + } else if vw >= 640. { + 24. + } else { + 0. + }; + let framed = vw >= 640.; + let (width, height) = if framed { + ((vw - 2. * pad).min(1280.), (vh - 2. * pad).min(960.)) + } else { + (vw, vh) + }; + let backdrop = theme + .background + .opacity(if theme.is_dark() { 0.64 } else { 0.6 }); + let (border, background) = (theme.border, theme.background); + let header = self.header(width, cx); + let body = self.body(vw, cx); + div() + .absolute() + .inset_0() + .key_context(CONTEXT) + .track_focus(&self.focus) + .on_action(cx.listener(|this, _: &CloseUsage, window, cx| this.close(window, cx))) + .on_action( + cx.listener(|this, _: &ShowCost, _, cx| this.select_metric(Metric::Cost, cx)), + ) + .on_action( + cx.listener(|this, _: &ShowTokens, _, cx| this.select_metric(Metric::Tokens, cx)), + ) + .on_action( + cx.listener(|this, _: &ShowLimits, _, cx| this.select_metric(Metric::Limits, cx)), + ) + .on_action(cx.listener(|this, _: &PastDay, _, cx| this.shortcut_window(1, cx))) + .on_action(cx.listener(|this, _: &PastWeek, _, cx| this.shortcut_window(7, cx))) + .on_action(cx.listener(|this, _: &PastMonth, _, cx| this.shortcut_window(30, cx))) + .on_action(cx.listener(|this, _: &PastQuarter, _, cx| this.shortcut_window(90, cx))) + .child( + div() + .id("usage-backdrop") + .absolute() + .inset_0() + .occlude() + .bg(backdrop) + .on_click(cx.listener(|this, _, window, cx| this.close(window, cx))), + ) + .child( + v_flex() + .absolute() + .left(px((vw - width) / 2.)) + .top(px((vh - height) / 2.)) + .w(px(width)) + .h(px(height)) + .overflow_hidden() + .occlude() + .bg(background) + .text_color(cx.theme().foreground) + .when(framed, |popup| { + popup + .rounded(px(16.)) + .border_1() + .border_color(border) + .shadow_2xl() + }) + .child(header) + .child( + div() + .id("usage-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .track_scroll(&self.scroll) + .child(body), + ), + ) + .into_any_element() + } +} + +impl UsageDialog { + fn header(&self, width: f32, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let (muted, border) = (theme.muted_foreground, theme.border); + let limits = self.prefs.metric == Metric::Limits; + let alert = !limits && self.failed && self.shown.is_none(); + let windows: [(u32, &'static str, &'static str); 4] = [ + (1, "Past 24h", "\u{21e7}\u{2318}1"), + (7, "7 days", "\u{21e7}\u{2318}2"), + (30, "30 days", "\u{21e7}\u{2318}3"), + (90, "90 days", "\u{21e7}\u{2318}4"), + ]; + let metrics: [(Metric, &'static str, &'static str); 3] = [ + (Metric::Cost, "Cost", "c"), + (Metric::Tokens, "Tokens", "t"), + (Metric::Limits, "Limits", "l"), + ]; + let period = toggle_group(cx).children(windows.map(|(days, label, shortcut)| { + toggle( + ("usage-window", days as usize), + label, + format!("{label} ({shortcut})"), + self.prefs.window_days == days, + cx.listener(move |this, _, _, cx| this.select_window(days, cx)), + cx, + ) + })); + let metric = toggle_group(cx).children(metrics.map(|(value, label, key)| { + toggle( + ("usage-metric", value as usize), + label, + format!("{label} ({key})"), + self.prefs.metric == value, + cx.listener(move |this, _, _, cx| this.select_metric(value, cx)), + cx, + ) + })); + let refreshing = self.manual; + let can_refresh = !refreshing && (limits || self.can_read()); + let icon = Icon::new(Icons::RefreshCw).size(px(14.)); + let spinner = if refreshing { + icon.with_animation( + "usage-refresh-spin", + Animation::new(Duration::from_secs(1)).repeat(), + |icon, delta| icon.rotate(radians(delta * std::f32::consts::TAU)), + ) + .into_any_element() + } else { + icon.into_any_element() + }; + let refresh = plain_icon_button("usage-refresh", spinner, can_refresh, cx) + .when(can_refresh, |button| { + button.on_click(cx.listener(|this, _, _, cx| this.refresh(cx))) + }); + let close = ghost_icon_button( + "usage-close", + Icon::new(IconName::Close).size(px(14.)), + "Close (Esc)".into(), + true, + cx, + ) + .on_click(cx.listener(|this, _, window, cx| this.close(window, cx))); + let window_label = if limits || width < 1280. { + None + } else if self.span.hourly { + match (&self.span.since_time, &self.span.until_time) { + (Some(since), Some(until)) => Some(format!( + "{} to {}", + data::format_date_time_short(since, &self.zone), + data::format_date_time_short(until, &self.zone) + )), + _ => None, + } + } else { + Some(format!( + "{} to {}", + data::format_day_short(&self.span.since_day), + data::format_day_short(&self.span.until_day) + )) + }; + h_flex() + .flex_shrink_0() + .min_h(px(48.)) + .items_center() + .gap_1() + .border_b_1() + .border_color(border) + .pl(px(20.)) + .pr(px(8.)) + .child( + h_flex() + .flex_1() + .min_w_0() + .items_center() + .gap_x_3() + .py_2() + .child( + h_flex() + .items_center() + .gap_3() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(div().text_color(muted).child("Usage")) + .child(div().text_color(muted).child("/")) + .child(filter_label("All environments", alert, cx)) + .child(div().text_color(muted).child("/")) + .child(filter_label("All providers", false, cx)), + ) + .children(window_label.map(|label| { + div() + .min_w_0() + .truncate() + .text_xs() + .text_color(muted) + .child(label) + })), + ) + .child( + h_flex() + .flex_shrink_0() + .items_center() + .gap_2() + .when(!limits, |row| row.child(period)) + .child(metric) + .child(refresh), + ) + .child(close) + .into_any_element() + } + + fn body(&self, vw: f32, cx: &mut Context) -> AnyElement { + let muted = cx.theme().muted_foreground; + let limits = self.prefs.metric == Metric::Limits; + let noun = if limits { "limits" } else { "usage" }; + let content = if self.access == Access::Unavailable { + div() + .text_sm() + .text_color(muted) + .child(format!("Connect an environment to see {noun}.")) + .into_any_element() + } else if limits { + self.limits_body(cx) + } else if self.access == Access::Offline { + div() + .py_12() + .text_center() + .text_sm() + .text_color(muted) + .child("This environment is not connected.") + .into_any_element() + } else if self.shown.is_none() && !self.failed { + self.skeleton(vw, cx) + } else { + self.overview(vw, cx) + }; + let side = if vw >= 640. { 24. } else { 20. }; + v_flex() + .mx_auto() + .w_full() + .max_w(px(CONTENT_MAX)) + .gap_6() + .px(px(side)) + .pt(px(24.)) + .pb(px(48.)) + .child(content) + .into_any_element() + } + + fn limits_body(&self, cx: &App) -> AnyElement { + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("Limits are not available here yet.") + .into_any_element() + } + + fn skeleton(&self, vw: f32, cx: &App) -> AnyElement { + let block = |w: Option, h: f32| skeleton_block(w, h, false, cx); + let wide = vw >= 1024.; + let rows = (0..8).map(|_| { + h_flex() + .items_center() + .justify_between() + .gap_4() + .child( + h_flex() + .items_center() + .gap_2() + .child(skeleton_block(Some(8.), 8., true, cx)) + .child(skeleton_block(Some(16.), 16., true, cx)) + .child(block(Some(80.), 14.)) + .child(block(Some(56.), 14.)), + ) + .child(block(Some(144.), 16.)) + }); + let left = v_flex() + .gap(px(20.)) + .child( + v_flex() + .gap_1() + .child(block(Some(144.), 40.)) + .child(block(Some(128.), 16.)), + ) + .children(rows); + let right = v_flex() + .gap_3() + .child(block(Some(96.), 20.)) + .child(div().ml(px(64.)).child(block(None, PLOT_HEIGHT))) + .child(div().ml(px(64.)).child(block(None, 16.))); + let totals = v_flex().gap_2().child(block(Some(64.), 20.)).child( + div() + .grid() + .grid_cols(if vw >= 768. { 5 } else { 2 }) + .gap_x_6() + .gap_y_4() + .py_1() + .children((0..5).map(|_| { + v_flex() + .gap(px(2.)) + .child(block(Some(80.), 16.)) + .child(block(Some(64.), 24.)) + })), + ); + let shares = div() + .grid() + .grid_cols(if wide { 2 } else { 1 }) + .gap_x(px(48.)) + .gap_y_8() + .children((0..2).map(|_| { + v_flex() + .gap(px(10.)) + .child(block(Some(112.), 20.)) + .child(block(None, 8.)) + .child(block(Some(288.), 16.)) + })); + let breakdown = v_flex() + .gap_3() + .child( + h_flex() + .gap_3() + .child(block(Some(80.), 20.)) + .child(block(Some(112.), 28.)), + ) + .child(block(None, 176.)); + v_flex() + .gap_6() + .child( + div() + .grid() + .grid_cols(if wide { 2 } else { 1 }) + .gap_6() + .child(left) + .child(right), + ) + .child(totals) + .child(shares) + .child(breakdown) + .into_any_element() + } +} + +struct ChartPaint { + grid: Vec, + series: Vec<(Vec, Hsla, bool)>, + hover_x: Option, + grid_color: Hsla, + hover_color: Hsla, +} + +fn curve_path( + curves: &[data::Curve], + map: &impl Fn(f64, f64) -> gpui::Point, + mut builder: PathBuilder, + close_to: Option, +) -> Option> { + let first = curves.first()?; + let last = curves.last()?; + builder.move_to(map(first.from.x, first.from.y)); + for curve in curves { + builder.cubic_bezier_to( + map(curve.to.x, curve.to.y), + map(curve.c1.x, curve.c1.y), + map(curve.c2.x, curve.c2.y), + ); + } + if let Some(bottom) = close_to { + builder.line_to(map(last.to.x, bottom)); + builder.line_to(map(first.from.x, bottom)); + builder.close(); + } + builder.build().ok() +} + +impl ChartPaint { + fn paint(&self, bounds: Bounds, window: &mut Window) { + let (ox, oy) = (f32::from(bounds.origin.x), f32::from(bounds.origin.y)); + let (w, h) = (f32::from(bounds.size.width), f32::from(bounds.size.height)); + let x_of = |x: f64| ox + x as f32 / data::VIEW_WIDTH as f32 * w; + let y_of = |y: f64| oy + y as f32 / data::VIEW_HEIGHT as f32 * h; + let map = |x: f64, y: f64| point(px(x_of(x)), px(y_of(y))); + for y in &self.grid { + let bounds = Bounds::new(point(px(ox), px(y_of(*y) - 0.5)), size(px(w), px(1.))); + window.paint_quad(fill(bounds, self.grid_color)); + } + for (curves, color, loading) in &self.series { + let alpha = dimmed(*loading); + if let Some(path) = + curve_path(curves, &map, PathBuilder::fill(), Some(data::VIEW_HEIGHT)) + { + window.paint_path(path, color.opacity(0.12 * alpha)); + } + } + for (curves, color, loading) in &self.series { + if let Some(path) = curve_path(curves, &map, PathBuilder::stroke(px(2.)), None) { + window.paint_path(path, color.opacity(dimmed(*loading))); + } + } + if let Some(x) = self.hover_x { + let top = y_of(data::PLOT_TOP); + let line = Bounds::new( + point(px(x_of(x) - 0.5), px(top)), + size(px(1.), px(oy + h - top)), + ); + window.paint_quad(fill(line, self.hover_color)); + } + } +} + +fn metric_format(metric: Metric) -> fn(f64) -> String { + if metric == Metric::Tokens { + data::format_tokens + } else { + data::format_usd + } +} + +fn section_title(text: impl Into) -> gpui::Div { + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(text.into()) +} + +impl UsageDialog { + fn overview(&self, vw: f32, cx: &mut Context) -> AnyElement { + let empty = Merged::default(); + let (merged, span, sources) = match &self.shown { + Some(shown) => (&shown.merged, &shown.span, &shown.summary.sources[..]), + None => (&empty, &self.span, &[][..]), + }; + let metric = if self.prefs.metric == Metric::Tokens { + Metric::Tokens + } else { + Metric::Cost + }; + let dim = self.dim(); + let partial = dim.partial(); + let with_data = data::active_providers(merged); + let active: Vec = Provider::ORDER + .into_iter() + .filter(|p| { + with_data.contains(p) || matches!(&dim, Dim::Providers(list) if list.contains(p)) + }) + .collect(); + let theme = cx.theme(); + let (muted, foreground) = (theme.muted_foreground, theme.foreground); + let wide = vw >= 1024.; + + let mut notes = data::source_messages(sources); + if let Some(mismatch) = merged.mismatch { + notes.push(data::contract_mismatch_message( + "This environment", + mismatch, + )); + } + let notes = notes + .into_iter() + .map(|message| div().mb_4().text_sm().text_color(muted).child(message)); + + let big = if metric == Metric::Cost { + data::format_usd(merged.cost) + } else { + data::format_tokens(merged.total_tokens) + }; + let sessions = format!("{} sessions", data::format_count(merged.sessions)); + let unpriced = (metric == Metric::Cost && merged.unpriced_share > 0.0).then(|| { + format!( + "API estimate excludes {} unpriced records.", + data::format_percent(merged.unpriced_share, 1) + ) + }); + let rows = active.iter().map(|provider| { + let provider = *provider; + let totals = merged.providers.iter().find(|t| t.provider == provider); + let share = totals.map_or(0.0, |t| { + if metric == Metric::Cost { + t.cost_share + } else { + t.token_share + } + }); + let count = totals.map_or(0.0, |t| t.sessions); + let loading = dim.provider(provider); + let awaiting = loading && !with_data.contains(&provider); + let (cost, tokens) = totals.map_or((0.0, 0.0), |t| (t.cost, t.tokens)); + let value = if awaiting { + "-".to_owned() + } else if metric == Metric::Cost { + data::format_usd(cost) + } else { + data::format_tokens(tokens) + }; + let line = if metric == Metric::Cost { + format!( + "{} of cost \u{b7} {} tokens", + data::format_percent(share, 1), + data::format_tokens(tokens) + ) + } else { + format!( + "{} of tokens \u{b7} {}", + data::format_percent(share, 1), + data::format_usd(cost) + ) + }; + let noun = if count == 1.0 { "session" } else { "sessions" }; + v_flex() + .gap_1() + .child( + h_flex() + .items_baseline() + .justify_between() + .gap_4() + .child( + h_flex() + .min_w_0() + .items_center() + .gap_2() + .text_sm() + .child( + div() + .size(px(8.)) + .flex_shrink_0() + .rounded_full() + .bg(tint(provider, cx)), + ) + .child(mark(provider, 16., cx)) + .child( + h_flex() + .min_w_0() + .items_baseline() + .gap(px(6.)) + .child(div().truncate().child(provider.label())) + .child( + div() + .flex_shrink_0() + .whitespace_nowrap() + .text_size(px(11.)) + .text_color(muted) + .opacity(dimmed(loading)) + .when(awaiting, |d| d.invisible()) + .child(format!( + "{} {noun}", + data::format_count(count) + )), + ), + ), + ) + .child( + div() + .flex_shrink_0() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .opacity(dimmed(loading)) + .child(value), + ), + ) + .child( + div() + .text_xs() + .text_color(muted) + .opacity(dimmed(loading)) + .when(awaiting, |d| d.invisible()) + .child(line), + ) + }); + let left = v_flex() + .min_w_0() + .gap(px(20.)) + .child( + v_flex() + .gap_1() + .child( + div() + .text_size(px(36.)) + .line_height(px(40.)) + .font_weight(FontWeight::SEMIBOLD) + .opacity(dimmed(partial)) + .child(big), + ) + .child( + h_flex() + .items_center() + .gap_1() + .text_xs() + .text_color(muted) + .child( + h_flex() + .child(div().opacity(dimmed(partial)).child(sessions)) + .when(metric == Metric::Cost, |row| { + row.child(" \u{b7} API estimate") + }), + ) + .children(unpriced.map(|text| { + div() + .id("usage-unpriced") + .child(Icon::new(Icons::Info).size(px(12.))) + .tooltip(tooltip(text)) + })), + ), + ) + .children(rows); + + let hourly = span.hourly; + let heading = format!( + "{} {}", + if hourly { "Hourly" } else { "Daily" }, + if metric == Metric::Tokens { + "processed tokens" + } else { + "cost" + } + ); + let loading_providers: Vec = active + .iter() + .copied() + .filter(|p| dim.provider(*p)) + .collect(); + let right = v_flex() + .min_w_0() + .gap_3() + .child(section_title(heading)) + .child(self.chart(merged, span, metric, &active, &loading_providers, cx)); + + let totals_cell = |label: &'static str, value: String| { + v_flex() + .gap(px(2.)) + .opacity(dimmed(partial)) + .child(div().text_xs().text_color(muted).child(label)) + .child( + div() + .text_base() + .font_weight(FontWeight::MEDIUM) + .child(value), + ) + }; + let totals = v_flex().gap_2().child(section_title("Totals")).child( + div() + .grid() + .grid_cols(if vw >= 768. { 5 } else { 2 }) + .gap_x_6() + .gap_y_4() + .py_1() + .child(totals_cell( + "Processed tokens", + data::format_tokens(merged.total_tokens), + )) + .child(totals_cell( + "Cached input", + data::format_tokens(merged.tokens.cached), + )) + .child(totals_cell( + "Uncached input", + data::format_tokens(merged.tokens.uncached), + )) + .child(totals_cell( + "Output", + data::format_tokens(merged.tokens.output), + )) + .child(totals_cell( + "Cache savings", + data::format_usd(merged.cache_savings), + )), + ); + + let shares = (merged.total_tokens > 0.0).then(|| { + let mut bars: Vec> = Vec::new(); + if metric == Metric::Tokens { + bars.push(share_bar( + "Tokens by type", + None, + &data::token_type_segments(&merged.tokens), + data::format_tokens, + cx, + )); + } else { + bars.push(share_bar( + "Cost by type", + None, + &data::cost_type_segments(&merged.category_cost), + data::format_usd, + cx, + )); + if merged.speed_cost.fast + merged.speed_cost.ultrafast > 0.0 { + let aside = h_flex() + .gap_1() + .text_xs() + .text_color(muted) + .child("Premium") + .child( + div() + .text_color(foreground) + .child(data::format_usd(merged.speed_cost.premium)), + ); + bars.push(share_bar( + "Cost by speed", + Some(aside), + &data::speed_cost_segments(&merged.speed_cost), + data::format_usd, + cx, + )); + } + } + div() + .grid() + .grid_cols(if wide { 2 } else { 1 }) + .gap_x(px(48.)) + .gap_y_8() + .opacity(dimmed(partial)) + .children(bars.into_iter().flatten()) + }); + + let breakdown = self.breakdown(vw, merged, span, metric, &active, &dim, cx); + + v_flex() + .gap_6() + .children(notes) + .child( + div() + .grid() + .grid_cols(if wide { 2 } else { 1 }) + .gap_6() + .child(left) + .child(right), + ) + .child(totals) + .children(shares) + .child(breakdown) + .into_any_element() + } + + fn chart( + &self, + merged: &Merged, + span: &Span, + metric: Metric, + active: &[Provider], + loading: &[Provider], + cx: &mut Context, + ) -> AnyElement { + let hourly = span.hourly; + let periods: Vec = match (&span.since_time, &span.until_time) { + (Some(since), Some(until)) if hourly => data::enumerate_hour_starts(since, until), + _ => data::enumerate_days(&span.since_day, &span.until_day), + }; + let totals = if hourly { + &merged.hourly + } else { + &merged.daily + }; + let chart = data::build_chart(&periods, totals, metric, active); + let peak = chart + .columns + .iter() + .flat_map(|c| c.bands.iter().map(|(_, v)| *v)) + .fold(0.0, f64::max); + let placeholder = peak == 0.0 && !loading.is_empty(); + let (max, ticks) = if placeholder { + (4.0, vec![0.0, 1.0, 2.0, 3.0, 4.0]) + } else { + (chart.max, chart.ticks.clone()) + }; + let format = metric_format(metric); + let zone = self.zone.clone(); + let label_of = { + let zone = zone.clone(); + move |period: &str| { + if hourly { + data::format_hour_short(period, &zone) + } else { + data::format_day_short(period) + } + } + }; + let theme = cx.theme(); + let (muted, border, popover) = (theme.muted_foreground, theme.border, theme.popover); + let ylabels = (!placeholder).then(|| { + ticks.iter().map(|tick| { + let y = data::value_to_y(*tick, max) / data::VIEW_HEIGHT * f64::from(PLOT_HEIGHT); + div() + .absolute() + .right_0() + .top(px(y as f32 - 7.)) + .h(px(14.)) + .text_size(px(10.)) + .line_height(px(14.)) + .text_color(muted) + .child(if *tick == 0.0 { + "0".to_owned() + } else { + format(*tick) + }) + }) + }); + let hover = self.hover.filter(|(index, _)| *index < periods.len()); + let paint = ChartPaint { + grid: ticks.iter().map(|t| data::value_to_y(*t, max)).collect(), + series: chart + .series + .iter() + .map(|s| { + ( + s.curve.clone(), + tint(s.provider, cx), + loading.contains(&s.provider), + ) + }) + .collect(), + hover_x: hover.map(|(index, _)| index as f64 * chart.step_x), + grid_color: border, + hover_color: muted, + }; + let count = periods.len(); + let plot_cell = self.plot.clone(); + let tip_cell = self.tip.clone(); + let tip_card = hover.and_then(|(index, at)| { + let column = chart.columns.get(index)?; + let period = periods.get(index)?; + let label = match (&span.until_time, hourly) { + (Some(until), true) => data::format_relative_hour_short(period, until, &zone), + _ => label_of(period), + }; + let plot = self.plot.get(); + let (pw, ph) = (f32::from(plot.size.width), f32::from(plot.size.height)); + let tip = self.tip.get(); + let (tw, th) = (f32::from(tip.width), f32::from(tip.height)); + let (x, y) = (f32::from(at.x), f32::from(at.y)); + let left = if x + 12. + tw <= pw { + x + 12. + } else { + x - 12. - tw + }; + let top = if y + 12. + th <= ph { + y + 12. + } else { + y - 12. - th + }; + let left = left.max(0.).min((pw - tw).max(0.)); + let top = top.max(0.).min((ph - th).max(0.)); + let partial = active.iter().any(|p| loading.contains(p)); + let rows = active.iter().map(|provider| { + let value = column + .bands + .iter() + .find(|(p, _)| p == provider) + .map_or(0.0, |(_, v)| *v); + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + h_flex() + .items_center() + .gap(px(6.)) + .text_color(muted) + .child(mark(*provider, 12., cx)) + .child(provider.label()), + ) + .child( + div() + .text_color(if loading.contains(provider) { + muted + } else { + cx.theme().foreground + }) + .child(format(value)), + ) + }); + Some( + v_flex() + .absolute() + .left(px(left)) + .top(px(top)) + .min_w(px(144.)) + .max_w_full() + .rounded(px(12.)) + .border_1() + .border_color(border.opacity(0.5)) + .bg(popover) + .px(px(10.)) + .py_2() + .text_xs() + .shadow_lg() + .child(div().mb_1().text_color(muted).child(label)) + .children(rows) + .child( + h_flex() + .mt_1() + .items_center() + .justify_between() + .gap_3() + .border_t_1() + .border_color(border) + .pt_1() + .child(div().text_color(muted).child("Total")) + .child( + div() + .text_color(if partial { + muted + } else { + cx.theme().foreground + }) + .child(format(column.total)), + ), + ) + .child( + canvas( + move |bounds, _, _| tip_cell.set(bounds.size), + |_, _, _, _| {}, + ) + .absolute() + .inset_0(), + ), + ) + }); + let plot = div() + .id("usage-plot") + .relative() + .h(px(PLOT_HEIGHT)) + .flex_1() + .on_mouse_move(cx.listener(move |this, event: &MouseMoveEvent, _, cx| { + let bounds = this.plot.get(); + let (w, h) = (f32::from(bounds.size.width), f32::from(bounds.size.height)); + if w <= 0. || count == 0 { + return; + } + let x = (f32::from(event.position.x - bounds.origin.x)).clamp(0., w); + let y = (f32::from(event.position.y - bounds.origin.y)).clamp(0., h); + let index = ((x / w) * (count - 1) as f32).round() as usize; + this.hover = Some((index.min(count - 1), point(px(x), px(y)))); + cx.notify(); + })) + .on_hover(cx.listener(|this, hovered: &bool, _, cx| { + if !*hovered && this.hover.take().is_some() { + cx.notify(); + } + })) + .child( + canvas( + move |bounds, _, _| plot_cell.set(bounds), + move |bounds, _, window, _| paint.paint(bounds, window), + ) + .absolute() + .inset_0(), + ) + .children(tip_card); + let first = periods.first().map(|p| label_of(p)).unwrap_or_default(); + let middle = periods + .get(periods.len() / 2) + .map(|p| label_of(p)) + .unwrap_or_default(); + let last = periods.last().map(|p| label_of(p)).unwrap_or_default(); + v_flex() + .gap_1() + .child( + h_flex() + .gap_2() + .child( + div() + .relative() + .h(px(PLOT_HEIGHT)) + .w(px(56.)) + .flex_shrink_0() + .children(ylabels.into_iter().flatten()), + ) + .child(plot), + ) + .child( + h_flex() + .justify_between() + .pl(px(64.)) + .text_size(px(10.)) + .text_color(muted) + .child(first.to_uppercase()) + .child(middle.to_uppercase()) + .child(last.to_uppercase()), + ) + .into_any_element() + } +} + +/// `UsageShareBar`; nothing when no segment has a value. +fn share_bar( + label: &'static str, + aside: Option, + segments: &[data::Segment], + format: fn(f64) -> String, + cx: &App, +) -> Option { + let visible: Vec<&data::Segment> = segments.iter().filter(|s| s.value > 0.0).collect(); + let total: f64 = visible.iter().map(|s| s.value).sum(); + if total <= 0.0 { + return None; + } + let theme = cx.theme(); + let (muted, foreground, background) = + (theme.muted_foreground, theme.foreground, theme.background); + let color = |segment: &data::Segment| { + background.blend(foreground.opacity(f32::from(segment.ink) / 100.)) + }; + let last = visible.len() - 1; + let bar = h_flex() + .h(px(8.)) + .gap(px(2.)) + .children(visible.iter().enumerate().map(|(index, segment)| { + let tip = format!( + "{} \u{b7} {} \u{b7} {}", + segment.label, + format(segment.value), + data::format_percent(segment.value / total, 1) + ); + div() + .id(("usage-share", index)) + .h_full() + .min_w(px(4.)) + .w(relative((segment.value / total) as f32)) + .rounded(px(2.)) + .when(index == 0, |d| d.rounded_tl(px(4.)).rounded_bl(px(4.))) + .when(index == last, |d| d.rounded_tr(px(4.)).rounded_br(px(4.))) + .bg(color(segment)) + .tooltip(tooltip(tip)) + })); + let legend = h_flex() + .flex_wrap() + .gap_x_4() + .gap_y_1() + .text_xs() + .children(visible.iter().map(|segment| { + h_flex() + .items_center() + .gap(px(6.)) + .child(div().size(px(8.)).rounded(px(2.)).bg(color(segment))) + .child(div().text_color(muted).child(segment.label)) + .child(div().text_color(foreground).child(format(segment.value))) + })); + Some( + v_flex() + .min_w_0() + .gap(px(10.)) + .child( + h_flex() + .items_baseline() + .justify_between() + .gap_3() + .child(section_title(label)) + .children(aside), + ) + .child(bar) + .child(legend), + ) +} + +impl UsageDialog { + #[allow(clippy::too_many_arguments)] + fn breakdown( + &self, + vw: f32, + merged: &Merged, + span: &Span, + metric: Metric, + active: &[Provider], + dim: &Dim, + cx: &mut Context, + ) -> AnyElement { + let time = self.breakdown_time; + let time_label = if span.hourly { "Hour" } else { "Day" }; + let switch = toggle_group(cx) + .child(toggle( + "usage-breakdown-model", + "Model", + "Model".to_owned(), + !time, + cx.listener(|this, _, _, cx| { + this.breakdown_time = false; + cx.notify(); + }), + cx, + )) + .child(toggle( + "usage-breakdown-time", + time_label, + time_label.to_owned(), + time, + cx.listener(|this, _, _, cx| { + this.breakdown_time = true; + cx.notify(); + }), + cx, + )); + let table = if time { + self.time_table(merged, span, active, dim, cx) + } else { + self.model_table(vw, merged, metric, dim, cx) + }; + v_flex() + .gap_3() + .child( + h_flex() + .items_center() + .justify_between() + .child(section_title("Breakdown")) + .child(switch), + ) + .child(table) + .into_any_element() + } + + fn model_table( + &self, + vw: f32, + merged: &Merged, + metric: Metric, + dim: &Dim, + cx: &mut Context, + ) -> gpui::Div { + let theme = cx.theme(); + let (muted, border, foreground, hover) = ( + theme.muted_foreground, + theme.border, + theme.foreground, + theme.muted.opacity(0.5), + ); + let wide = vw >= 640.; + let by_tokens = metric == Metric::Tokens; + let models = if by_tokens { + data::sort_models_by_tokens(&merged.models) + } else { + merged.models.clone() + }; + let value_of = |m: &data::ModelTotals| if by_tokens { m.tokens } else { m.cost }; + let peak = models.iter().map(value_of).fold(0.0, f64::max); + let head = |label: &'static str, width: Option| { + div() + .when_some(width, |d, w| d.w(px(w)).flex_shrink_0()) + .when(width.is_none(), |d| d.flex_1()) + .text_right() + .child(label) + }; + let header = h_flex() + .py_2() + .px_2() + .gap_x(px(0.)) + .border_b_1() + .border_color(border) + .text_xs() + .text_color(muted) + .child(div().w(px(36.)).flex_shrink_0().text_left().child("#")) + .child(head("Model", None).text_left()) + .child(head("Cost", Some(96.))) + .when(wide, |row| row.child(head("Share", Some(80.)))) + .child(head("Tokens", Some(96.))); + let rows = models.iter().enumerate().map(|(index, model)| { + let loading = dim.provider(model.provider); + let value = value_of(model); + let bar = value > 0.0 && peak > 0.0; + let cost = if model.cost_unknown() { + div().text_color(muted).child("Unpriced") + } else { + div() + .text_color(foreground) + .child(data::format_usd(model.cost)) + }; + let share = data::model_share(model, metric) + .map(|s| data::format_percent(s, 1)) + .unwrap_or_default(); + let tint = tint(model.provider, cx); + h_flex() + .id(("usage-model", index)) + .px_2() + .gap_x(px(0.)) + .border_b_1() + .border_color(border.opacity(0.5)) + .text_sm() + .text_color(muted) + .opacity(dimmed(loading)) + .hover(move |style| style.bg(hover)) + .child( + div() + .w(px(36.)) + .flex_shrink_0() + .py(px(10.)) + .text_xs() + .child((index + 1).to_string()), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .py(px(if bar { 10. } else { 14. })) + .child( + h_flex() + .items_center() + .gap_2() + .text_color(foreground) + .child(mark(model.provider, 14., cx)) + .child(div().truncate().child(model.model.clone())), + ) + .when(bar, |col| { + col.child( + div().mt(px(6.)).h(px(2.)).max_w(px(192.)).child( + div() + .h_full() + .min_w(px(8.)) + .w(relative((value / peak) as f32)) + .rounded_full() + .bg(tint), + ), + ) + }), + ) + .child( + div() + .w(px(96.)) + .flex_shrink_0() + .text_right() + .py(px(10.)) + .child(cost), + ) + .when(wide, |row| { + row.child( + div() + .w(px(80.)) + .flex_shrink_0() + .text_right() + .py(px(10.)) + .child(share), + ) + }) + .child( + div() + .w(px(96.)) + .flex_shrink_0() + .text_right() + .py(px(10.)) + .child(data::format_tokens(model.tokens)), + ) + }); + v_flex() + .child(header) + .when(models.is_empty(), |t| { + t.child( + div() + .py_6() + .text_center() + .text_sm() + .text_color(muted) + .child("No activity in this window."), + ) + }) + .children(rows) + } + + fn time_table( + &self, + merged: &Merged, + span: &Span, + active: &[Provider], + dim: &Dim, + cx: &mut Context, + ) -> gpui::Div { + let theme = cx.theme(); + let (muted, border, foreground, hover) = ( + theme.muted_foreground, + theme.border, + theme.foreground, + theme.muted.opacity(0.5), + ); + let hourly = span.hourly; + let periods: Vec<&data::PeriodTotals> = if hourly { + merged.hourly.iter().rev().collect() + } else { + merged.daily.iter().rev().collect() + }; + let zone = self.zone.clone(); + let partial = dim.partial(); + let cell = |width: f32| div().w(px(width)).flex_shrink_0().text_right(); + let header = h_flex() + .py_2() + .px_2() + .border_b_1() + .border_color(border) + .text_xs() + .text_color(muted) + .child( + div() + .flex_1() + .min_w(px(120.)) + .child(if hourly { "Hour" } else { "Day" }), + ) + .children(active.iter().map(|p| cell(96.).child(p.label()))) + .child(cell(96.).child("Total")) + .child(cell(96.).child("Tokens")); + let rows = periods.iter().enumerate().map(|(index, period)| { + let label = if hourly { + data::format_hour_short(&period.key, &zone) + } else { + data::format_day_short(&period.key) + }; + h_flex() + .id(("usage-period", index)) + .px_2() + .py_2() + .border_b_1() + .border_color(border.opacity(0.5)) + .text_sm() + .whitespace_nowrap() + .hover(move |style| style.bg(hover)) + .child( + div() + .flex_1() + .min_w(px(120.)) + .text_color(foreground) + .child(label), + ) + .children(active.iter().map(|provider| { + let cost = period + .by_provider + .iter() + .find(|(p, _)| p == provider) + .map_or(0.0, |(_, share)| share.cost); + cell(96.) + .text_color(muted) + .opacity(dimmed(dim.provider(*provider))) + .child(data::format_usd(cost)) + })) + .child( + cell(96.) + .text_color(foreground) + .opacity(dimmed(partial)) + .child(data::format_usd(period.cost)), + ) + .child( + cell(96.) + .text_color(muted) + .opacity(dimmed(partial)) + .child(data::format_tokens(period.tokens)), + ) + }); + v_flex() + .overflow_x_hidden() + .child(header) + .when(periods.is_empty(), |t| { + t.child( + div() + .py_6() + .text_center() + .text_sm() + .text_color(muted) + .child("No activity in this window."), + ) + }) + .children(rows) + } +} diff --git a/apps/desktop-gpui/src/usage_data.rs b/apps/desktop-gpui/src/usage_data.rs new file mode 100644 index 000000000000..14b2a1f50a9b --- /dev/null +++ b/apps/desktop-gpui/src/usage_data.rs @@ -0,0 +1,2095 @@ +//! Pure logic behind the Usage dialog: decoding `server.getUsageSummary`, merging it, formatting +//! and chart math. Each part ports the web module named beside it. + +use std::path::{Path, PathBuf}; + +use serde_json::{Value, json}; + +use crate::shell::timestamp_ms; + +/// `USAGE_CONTRACT_VERSION` and `USAGE_MERGE_COMPATIBLE_SINCE`, packages/contracts/src/usage.ts. +pub const CONTRACT_VERSION: u64 = 6; +const MERGE_COMPATIBLE_SINCE: u64 = 4; + +const HOUR_MS: i64 = 3_600_000; +const DAY_MS: i64 = 86_400_000; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Provider { + Codex, + Claude, + Grok, + Cursor, + OpenCode, + Antigravity, + Pi, + Muse, +} + +/// How a provider color resolves against the theme (usageProviders.ts). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tint { + Fixed(u32), + Contrast, + ContrastSoft, +} + +impl Provider { + /// PROVIDER_ORDER, apps/web/src/components/usage/usageProviders.ts. + pub const ORDER: [Provider; 8] = [ + Provider::Codex, + Provider::Claude, + Provider::Grok, + Provider::Cursor, + Provider::OpenCode, + Provider::Antigravity, + Provider::Pi, + Provider::Muse, + ]; + + pub fn slot(self) -> usize { + Self::ORDER.iter().position(|p| *p == self).unwrap_or(0) + } + + fn from_wire(value: &str) -> Option { + Some(match value { + "codex" => Self::Codex, + "claude" => Self::Claude, + "grok" => Self::Grok, + "cursor" => Self::Cursor, + "opencode" => Self::OpenCode, + "antigravity" => Self::Antigravity, + "pi" => Self::Pi, + "muse" => Self::Muse, + _ => return None, + }) + } + + pub fn label(self) -> &'static str { + match self { + Self::Codex => "Codex", + Self::Claude => "Claude Code", + Self::Grok => "Grok Build", + Self::Cursor => "Cursor", + Self::OpenCode => "OpenCode", + Self::Antigravity => "Antigravity", + Self::Pi => "Pi", + Self::Muse => "Muse Code", + } + } + + pub fn driver_kind(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::Claude => "claudeAgent", + Self::Grok => "grok", + Self::Cursor => "cursor", + Self::OpenCode => "opencode", + Self::Antigravity => "antigravity", + Self::Pi => "pi", + Self::Muse => "muse", + } + } + + pub fn tint(self) -> Tint { + match self { + Self::Codex => Tint::Contrast, + Self::Claude => Tint::Fixed(0xd97757), + Self::Grok => Tint::ContrastSoft, + Self::Cursor => Tint::Fixed(0x8b8b8b), + Self::OpenCode => Tint::Fixed(0x5b9bbd), + Self::Antigravity => Tint::Fixed(0x8c7bd1), + Self::Pi => Tint::Fixed(0x4d9f8a), + Self::Muse => Tint::Fixed(0x0866ff), + } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Tokens { + pub uncached: f64, + pub cached: f64, + pub cache_creation: f64, + pub output: f64, + pub reasoning: f64, +} + +impl Tokens { + /// Reasoning is a subset of output and is not added again (usageMerge.ts bucketTokens). + pub fn total(&self) -> f64 { + self.uncached + self.cached + self.cache_creation + self.output + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Bucket { + pub day: String, + pub hour_start: Option, + pub provider: Provider, + pub model: String, + pub source_path: Option, + pub totals: Tokens, + pub cost_usd: f64, + pub cache_savings_usd: f64, + /// input, cache read, cache write, output + pub category_cost: Option<[f64; 4]>, + pub fast_cost: f64, + pub ultrafast_cost: f64, + pub speed_premium: f64, + pub records: f64, + pub unpriced_records: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SourceStatus { + Ok, + Missing, + Partial, + Failed, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Source { + pub host_id: String, + pub provider: Provider, + pub home: String, + pub volume_id: String, + pub status: SourceStatus, + pub malformed: f64, + pub distinct_sessions: f64, + pub message: Option, + pub has_action: bool, + pub refreshing: bool, +} + +impl Source { + fn key(&self) -> [&str; 4] { + [ + &self.host_id, + self.provider.driver_kind(), + &self.home, + &self.volume_id, + ] + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Summary { + pub contract_version: u64, + pub time_zone: String, + pub buckets: Vec, + pub sources: Vec, +} + +fn text(value: &Value, key: &str) -> Option { + let trimmed = value.get(key)?.as_str()?.trim(); + (!trimmed.is_empty()).then(|| trimmed.to_owned()) +} + +fn int(value: &Value, key: &str) -> Option { + let n = value.get(key)?.as_f64()?; + (n >= 0.0 && n.fract() == 0.0).then_some(n) +} + +fn num(value: &Value, key: &str) -> Option { + value.get(key)?.as_f64() +} + +fn is_day(day: &str) -> bool { + let b = day.as_bytes(); + b.len() == 10 + && b.iter().enumerate().all(|(i, c)| { + if i == 4 || i == 7 { + *c == b'-' + } else { + c.is_ascii_digit() + } + }) +} + +fn decode_bucket(value: &Value) -> Option { + let day = text(value, "day").filter(|d| is_day(d))?; + let totals = value.get("totals")?; + let category_cost = match value.get("categoryCostUsd") { + None => None, + Some(c) => Some([ + num(c, "input")?, + num(c, "cacheRead")?, + num(c, "cacheWrite")?, + num(c, "output")?, + ]), + }; + let optional = |key| match value.get(key) { + None => Some(0.0), + Some(v) => v.as_f64(), + }; + let cost_source = value.get("costSource")?.as_str()?; + if !matches!(cost_source, "providerReported" | "modelPriced" | "unpriced") { + return None; + } + int(value, "sessions")?; + Some(Bucket { + day, + hour_start: match value.get("hourStart") { + None => None, + Some(_) => Some(text(value, "hourStart")?), + }, + provider: Provider::from_wire(value.get("provider")?.as_str()?)?, + model: text(value, "model")?, + source_path: match value.get("sourcePath") { + None => None, + Some(_) => Some(text(value, "sourcePath")?), + }, + totals: Tokens { + uncached: int(totals, "uncachedInputTokens")?, + cached: int(totals, "cachedInputTokens")?, + cache_creation: int(totals, "cacheCreationTokens")?, + output: int(totals, "outputTokens")?, + reasoning: int(totals, "reasoningTokens")?, + }, + cost_usd: num(value, "costUsd")?, + cache_savings_usd: num(value, "cacheSavingsUsd")?, + category_cost, + fast_cost: optional("fastCostUsd")?, + ultrafast_cost: optional("ultrafastCostUsd")?, + speed_premium: optional("speedPremiumUsd")?, + records: int(value, "records")?, + unpriced_records: int(value, "unpricedRecords")?, + }) +} + +fn decode_source(value: &Value) -> Option { + let fingerprint = value.get("fingerprint")?; + let status = match value.get("status")?.as_str()? { + "ok" => SourceStatus::Ok, + "missing" => SourceStatus::Missing, + "partial" => SourceStatus::Partial, + "failed" => SourceStatus::Failed, + _ => return None, + }; + int(value, "scannedFiles")?; + int(value, "skippedFiles")?; + let message = match value.get("message")? { + Value::Null => None, + _ => Some(text(value, "message")?), + }; + let has_action = match value.get("action") { + None => false, + Some(a) if a == "enableCursorKeychain" => true, + Some(_) => return None, + }; + let refreshing = match value.get("refreshing") { + None => false, + Some(Value::Bool(true)) => true, + Some(_) => return None, + }; + Some(Source { + host_id: text(fingerprint, "hostId")?, + provider: Provider::from_wire(fingerprint.get("provider")?.as_str()?)?, + home: text(fingerprint, "resolvedHomePath")?, + volume_id: fingerprint.get("volumeId")?.as_str()?.to_owned(), + status, + malformed: int(value, "malformedRecords")?, + distinct_sessions: int(value, "distinctSessions")?, + message, + has_action, + refreshing, + }) +} + +fn valid_pricing(value: &Value) -> bool { + let fetched = matches!(value.get("fetchedAt"), Some(Value::Null | Value::String(_))); + matches!( + value.get("status").and_then(Value::as_str), + Some("fresh" | "cached" | "unavailable") + ) && text(value, "source").is_some() + && fetched + && int(value, "knownModels").is_some() +} + +impl Summary { + /// Mirrors the `UsageSummary` schema; array elements that fail it are dropped, as + /// `ForwardCompatibleArray` does. + pub fn decode(value: &Value) -> Option { + let contract_version = value.get("contractVersion")?.as_f64()?; + value.get("readAt")?.as_str()?; + let time_zone = text(value, "timeZone")?; + text(value, "sinceDay").filter(|d| is_day(d))?; + text(value, "untilDay").filter(|d| is_day(d))?; + if !valid_pricing(value.get("pricing")?) { + return None; + } + int(value, "scanDurationMs")?; + let buckets = value.get("buckets")?.as_array()?; + let sources = value.get("sources")?.as_array()?; + Some(Self { + contract_version: contract_version as u64, + time_zone, + buckets: buckets.iter().filter_map(decode_bucket).collect(), + sources: sources.iter().filter_map(decode_source).collect(), + }) + } + + pub fn refreshing(&self) -> bool { + self.sources.iter().any(|s| s.refreshing) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Share { + pub cost: f64, + pub tokens: f64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ProviderTotals { + pub provider: Provider, + pub cost: f64, + pub tokens: f64, + pub sessions: f64, + pub cost_share: f64, + pub token_share: f64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct ModelTotals { + pub model: String, + pub provider: Provider, + pub cost: f64, + pub tokens: f64, + pub records: f64, + pub unpriced_records: f64, + pub cost_share: f64, + pub token_share: f64, +} + +impl ModelTotals { + /// When every record lacks rates, the cost is unknown and is never shown as a dollar figure. + pub fn cost_unknown(&self) -> bool { + self.records > 0.0 && self.unpriced_records >= self.records + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PeriodTotals { + pub key: String, + pub cost: f64, + pub tokens: f64, + pub by_provider: Vec<(Provider, Share)>, +} + +impl PeriodTotals { + fn provider(&self, provider: Provider) -> Option { + self.by_provider + .iter() + .find(|(p, _)| *p == provider) + .map(|(_, s)| *s) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct CategoryCost { + pub input: f64, + pub cache_read: f64, + pub cache_write: f64, + pub output: f64, + pub unsplit: f64, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct SpeedCost { + pub standard: f64, + pub fast: f64, + pub ultrafast: f64, + pub premium: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mismatch { + ServerBehind, + ClientBehind, +} + +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Merged { + pub cost: f64, + pub tokens: Tokens, + pub total_tokens: f64, + pub sessions: f64, + pub providers: Vec, + pub models: Vec, + pub daily: Vec, + pub hourly: Vec, + pub unpriced_share: f64, + pub cache_savings: f64, + pub category_cost: CategoryCost, + pub speed_cost: SpeedCost, + pub mismatch: Option, +} + +/// `isCompatibleUsageContractVersion`, usageMerge.ts. +fn compatible(version: u64) -> bool { + (MERGE_COMPATIBLE_SINCE..=CONTRACT_VERSION).contains(&version) +} + +fn entry<'a, T>(list: &'a mut Vec<(String, T)>, key: &str, make: impl FnOnce() -> T) -> &'a mut T { + let at = match list.iter().position(|(k, _)| k == key) { + Some(at) => at, + None => { + list.push((key.to_owned(), make())); + list.len() - 1 + } + }; + &mut list[at].1 +} + +fn add_share(by_provider: &mut Vec<(Provider, Share)>, provider: Provider, cost: f64, tokens: f64) { + let at = match by_provider.iter().position(|(p, _)| *p == provider) { + Some(at) => at, + None => { + by_provider.push((provider, Share::default())); + by_provider.len() - 1 + } + }; + by_provider[at].1.cost += cost; + by_provider[at].1.tokens += tokens; +} + +fn ratio(part: f64, whole: f64) -> f64 { + if whole == 0.0 { 0.0 } else { part / whole } +} + +fn by_desc(a: f64, b: f64) -> std::cmp::Ordering { + b.partial_cmp(&a).unwrap_or(std::cmp::Ordering::Equal) +} + +/// `mergeUsage` (usageMerge.ts) for the one environment this app reads: the source claiming +/// order is ok, partial, failed, a repeated fingerprint is skipped, and only claimed +/// providers and source paths contribute buckets. +pub fn merge(summary: &Summary) -> Merged { + if !compatible(summary.contract_version) { + return Merged { + mismatch: Some(if summary.contract_version < CONTRACT_VERSION { + Mismatch::ServerBehind + } else { + Mismatch::ClientBehind + }), + ..Merged::default() + }; + } + + let mut owned: Vec<([&str; 4], f64)> = Vec::new(); + for status in [ + SourceStatus::Ok, + SourceStatus::Partial, + SourceStatus::Failed, + ] { + for source in summary.sources.iter().filter(|s| s.status == status) { + let key = source.key(); + if !owned.iter().any(|(k, _)| *k == key) { + owned.push((key, source.distinct_sessions)); + } + } + } + + let mut owned_providers: Vec = Vec::new(); + let mut owned_paths: Vec<(Provider, &str)> = Vec::new(); + let mut sessions_by_provider: Vec<(Provider, f64)> = Vec::new(); + for source in &summary.sources { + if source.status == SourceStatus::Missing { + continue; + } + let key = source.key(); + let Some((_, sessions)) = owned.iter().find(|(k, _)| *k == key) else { + continue; + }; + owned_providers.push(source.provider); + owned_paths.push((source.provider, &source.home)); + match sessions_by_provider + .iter_mut() + .find(|(p, _)| *p == source.provider) + { + Some((_, total)) => *total += sessions, + None => sessions_by_provider.push((source.provider, *sessions)), + } + } + + let mut merged = Merged::default(); + let mut records = 0.0; + let mut unpriced_records = 0.0; + let mut category = [0.0; 4]; + let mut providers: Vec<(Provider, ProviderTotals)> = Vec::new(); + let mut models: Vec<(String, ModelTotals)> = Vec::new(); + let mut daily: Vec<(String, PeriodTotals)> = Vec::new(); + let mut hourly: Vec<(String, PeriodTotals)> = Vec::new(); + + let blank = |provider| ProviderTotals { + provider, + cost: 0.0, + tokens: 0.0, + sessions: 0.0, + cost_share: 0.0, + token_share: 0.0, + }; + for (provider, sessions) in &sessions_by_provider { + merged.sessions += sessions; + if *sessions == 0.0 { + continue; + } + providers.push((*provider, blank(*provider))); + providers.last_mut().expect("pushed").1.sessions += sessions; + } + + for bucket in &summary.buckets { + let contributes = match &bucket.source_path { + None => owned_providers.contains(&bucket.provider), + Some(path) => owned_paths.contains(&(bucket.provider, path.as_str())), + }; + if !contributes { + continue; + } + let tokens = bucket.totals.total(); + merged.cost += bucket.cost_usd; + merged.cache_savings += bucket.cache_savings_usd; + merged.tokens.uncached += bucket.totals.uncached; + merged.tokens.cached += bucket.totals.cached; + merged.tokens.cache_creation += bucket.totals.cache_creation; + merged.tokens.output += bucket.totals.output; + merged.tokens.reasoning += bucket.totals.reasoning; + records += bucket.records; + unpriced_records += bucket.unpriced_records; + if let Some(c) = bucket.category_cost { + for (total, part) in category.iter_mut().zip(c) { + *total += part; + } + } + merged.speed_cost.fast += bucket.fast_cost; + merged.speed_cost.ultrafast += bucket.ultrafast_cost; + merged.speed_cost.premium += bucket.speed_premium; + + let at = match providers.iter().position(|(p, _)| *p == bucket.provider) { + Some(at) => at, + None => { + providers.push((bucket.provider, blank(bucket.provider))); + providers.len() - 1 + } + }; + providers[at].1.cost += bucket.cost_usd; + providers[at].1.tokens += tokens; + + let model_key = format!("{} {}", bucket.provider.driver_kind(), bucket.model); + let model = entry(&mut models, &model_key, || ModelTotals { + model: bucket.model.clone(), + provider: bucket.provider, + cost: 0.0, + tokens: 0.0, + records: 0.0, + unpriced_records: 0.0, + cost_share: 0.0, + token_share: 0.0, + }); + model.cost += bucket.cost_usd; + model.tokens += tokens; + model.records += bucket.records; + model.unpriced_records += bucket.unpriced_records; + + let period = |key: &str| PeriodTotals { + key: key.to_owned(), + cost: 0.0, + tokens: 0.0, + by_provider: Vec::new(), + }; + let day = entry(&mut daily, &bucket.day, || period(&bucket.day)); + day.cost += bucket.cost_usd; + day.tokens += tokens; + add_share( + &mut day.by_provider, + bucket.provider, + bucket.cost_usd, + tokens, + ); + if let Some(hour_start) = &bucket.hour_start { + let hour = entry(&mut hourly, hour_start, || period(hour_start)); + hour.cost += bucket.cost_usd; + hour.tokens += tokens; + add_share( + &mut hour.by_provider, + bucket.provider, + bucket.cost_usd, + tokens, + ); + } + } + + merged.total_tokens = merged.tokens.uncached + + merged.tokens.cached + + merged.tokens.cache_creation + + merged.tokens.output; + let (cost, total) = (merged.cost, merged.total_tokens); + + merged.providers = providers + .into_iter() + .map(|(_, mut p)| { + p.cost_share = ratio(p.cost, cost); + p.token_share = ratio(p.tokens, total); + p + }) + .collect(); + merged.providers.sort_by(|a, b| by_desc(a.cost, b.cost)); + merged.models = models + .into_iter() + .map(|(_, mut m)| { + m.cost_share = ratio(m.cost, cost); + m.token_share = ratio(m.tokens, total); + m + }) + .collect(); + merged + .models + .sort_by(|a, b| by_desc(a.cost, b.cost).then(by_desc(a.tokens, b.tokens))); + merged.daily = daily.into_iter().map(|(_, d)| d).collect(); + merged.daily.sort_by(|a, b| a.key.cmp(&b.key)); + merged.hourly = hourly.into_iter().map(|(_, h)| h).collect(); + merged.hourly.sort_by(|a, b| a.key.cmp(&b.key)); + + merged.unpriced_share = ratio(unpriced_records, records); + merged.category_cost = CategoryCost { + input: category[0], + cache_read: category[1], + cache_write: category[2], + output: category[3], + unsplit: (cost - category.iter().sum::()).max(0.0), + }; + merged.speed_cost.standard = + (cost - merged.speed_cost.fast - merged.speed_cost.ultrafast).max(0.0); + merged +} + +/// usageBreakdown.ts `sortModelsByTokens`. +pub fn sort_models_by_tokens(models: &[ModelTotals]) -> Vec { + let mut sorted = models.to_vec(); + sorted.sort_by(|a, b| by_desc(a.tokens, b.tokens).then(by_desc(a.cost, b.cost))); + sorted +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Metric { + Cost, + Tokens, + Limits, +} + +/// usageBreakdown.ts `modelShare`. +pub fn model_share(model: &ModelTotals, metric: Metric) -> Option { + if metric == Metric::Tokens { + return Some(model.token_share); + } + (!model.cost_unknown()).then_some(model.cost_share) +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Segment { + pub label: &'static str, + pub value: f64, + /// Foreground over background, in percent (`ink` in usageBreakdown.ts). + pub ink: u8, +} + +pub fn cost_type_segments(cost: &CategoryCost) -> [Segment; 5] { + let seg = |label, value, ink| Segment { label, value, ink }; + [ + seg("Input", cost.input, 60), + seg("Cache read", cost.cache_read, 30), + seg("Cache write", cost.cache_write, 72), + seg("Output", cost.output, 100), + seg( + "Other", + if cost.unsplit >= 0.005 { + cost.unsplit + } else { + 0.0 + }, + 44, + ), + ] +} + +pub fn token_type_segments(tokens: &Tokens) -> [Segment; 4] { + let seg = |label, value, ink| Segment { label, value, ink }; + [ + seg("Input", tokens.uncached, 60), + seg("Cache read", tokens.cached, 30), + seg("Cache write", tokens.cache_creation, 72), + seg("Output", tokens.output, 100), + ] +} + +pub fn speed_cost_segments(cost: &SpeedCost) -> [Segment; 3] { + let seg = |label, value, ink| Segment { label, value, ink }; + [ + seg("Standard", cost.standard, 34), + seg("Fast", cost.fast, 66), + seg("Ultrafast", cost.ultrafast, 100), + ] +} + +/// `providersWithUsage`, usageProviders.ts. +pub fn active_providers(merged: &Merged) -> Vec { + Provider::ORDER + .into_iter() + .filter(|provider| { + merged + .providers + .iter() + .any(|p| p.provider == *provider && (p.tokens > 0.0 || p.cost > 0.0)) + }) + .collect() +} + +// Formatting, packages/shared/src/usageFormat.ts. + +fn group(digits: &str) -> String { + let mut out = String::new(); + for (i, c) in digits.chars().enumerate() { + if i > 0 && (digits.len() - i).is_multiple_of(3) { + out.push(','); + } + out.push(c); + } + out +} + +fn round_half_up(value: f64) -> f64 { + (value + 0.5).floor() +} + +pub fn format_count(value: f64) -> String { + let rounded = round_half_up(value); + let sign = if rounded < 0.0 { "-" } else { "" }; + format!("{sign}{}", group(&format!("{:.0}", rounded.abs()))) +} + +pub fn format_usd(value: f64) -> String { + let fixed = format!("{:.2}", value.abs()); + let (whole, cents) = fixed.split_once('.').unwrap_or((&fixed, "00")); + let sign = if value < 0.0 && fixed != "0.00" { + "-" + } else { + "" + }; + format!("{sign}${}.{cents}", group(whole)) +} + +fn trim(value: f64) -> String { + let abs = value.abs(); + let digits = if abs >= 100.0 { + 0 + } else if abs >= 10.0 { + 1 + } else { + 2 + }; + let fixed = format!("{value:.digits$}"); + match fixed.split_once('.') { + Some((whole, frac)) if frac.bytes().all(|b| b == b'0') => whole.to_owned(), + _ => fixed, + } +} + +pub fn format_tokens(value: f64) -> String { + let abs = value.abs(); + for (floor, suffix) in [(1e12, "T"), (1e9, "B"), (1e6, "M"), (1e3, "K")] { + if abs >= floor { + return format!("{}{suffix}", trim(value / floor)); + } + } + format_count(value) +} + +pub fn format_percent(share: f64, digits: usize) -> String { + let percent = share * 100.0; + let smallest = 10f64.powi(-(digits as i32)); + if percent > 0.0 && percent < smallest { + return format!("<{smallest:.digits$}%"); + } + format!("{percent:.digits$}%") +} + +const MONTHS: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", +]; + +pub fn format_day_short(day: &str) -> String { + let mut parts = day.split('-').map(|p| p.parse::().ok()); + match ( + parts.next().flatten(), + parts.next().flatten(), + parts.next().flatten(), + ) { + (Some(_), Some(month), Some(day_of_month)) => format!( + "{} {day_of_month}", + month + .checked_sub(1) + .and_then(|i| MONTHS.get(i)) + .unwrap_or(&"") + ), + _ => day.to_owned(), + } +} + +pub fn malformed_warning(count: f64) -> String { + format!( + "{} malformed or inconsistent usage record(s) were skipped; usage may be incomplete.", + count as u64 + ) +} + +/// `usageSourceMessages`, usageFormat.ts. +pub fn source_messages(sources: &[Source]) -> Vec { + let mut messages: Vec = Vec::new(); + let mut malformed: Vec<([String; 4], f64)> = Vec::new(); + for source in sources { + if source.has_action + || (!matches!(source.status, SourceStatus::Partial | SourceStatus::Failed) + && source.provider != Provider::Cursor + && !(source.status == SourceStatus::Missing && source.malformed > 0.0)) + { + continue; + } + let mut message = source.message.clone(); + if source.malformed > 0.0 { + let key = source.key().map(str::to_owned); + match malformed.iter_mut().find(|(k, _)| *k == key) { + Some((_, count)) => *count = count.max(source.malformed), + None => malformed.push((key, source.malformed)), + } + let warning = malformed_warning(source.malformed); + if let Some(text) = &message + && let Some(head) = text.strip_suffix(&warning) + { + message = Some(head.trim().to_owned()); + } + } + if let Some(text) = message + && !text.is_empty() + && !messages.contains(&text) + { + messages.push(text); + } + } + let total: f64 = malformed.iter().map(|(_, c)| c).sum(); + if total > 0.0 { + let warning = malformed_warning(total); + if !messages.contains(&warning) { + messages.push(warning); + } + } + messages +} + +pub fn contract_mismatch_message(label: &str, mismatch: Mismatch) -> String { + match mismatch { + Mismatch::ServerBehind => { + format!("{label} runs an older server version and is excluded from totals.") + } + Mismatch::ClientBehind => { + format!( + "This client is older than the server on {label}; its usage is excluded from totals." + ) + } + } +} + +// Calendar math. The web formats through Intl in the viewer's zone; the local zone here comes +// from libc. + +fn civil_from_days(days: i64) -> (i64, i64, i64) { + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + (yoe + era * 400 + i64::from(month <= 2), month, day) +} + +fn format_day(days: i64) -> String { + let (y, m, d) = civil_from_days(days); + format!("{y:04}-{m:02}-{d:02}") +} + +pub fn iso_from_ms(ms: i64) -> String { + let days = ms.div_euclid(DAY_MS); + let rest = ms.rem_euclid(DAY_MS); + format!( + "{}T{:02}:{:02}:{:02}.{:03}Z", + format_day(days), + rest / HOUR_MS, + rest % HOUR_MS / 60_000, + rest % 60_000 / 1_000, + rest % 1_000 + ) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Zone { + Utc, + Local(String), +} + +impl Zone { + /// The IANA name from `TZ` or the `/etc/localtime` link, or UTC when neither names one. + pub fn detect() -> Self { + let from_env = std::env::var("TZ") + .ok() + .map(|tz| tz.trim_start_matches(':').to_owned()) + .filter(|tz| !tz.is_empty()); + let name = from_env.or_else(|| { + let link = std::fs::read_link("/etc/localtime").ok()?; + let link = link.to_string_lossy(); + link.split_once("zoneinfo/") + .map(|(_, name)| name.to_owned()) + }); + match name { + Some(name) if name != "UTC" => Self::Local(name), + _ => Self::Utc, + } + } + + pub fn name(&self) -> &str { + match self { + Self::Utc => "UTC", + Self::Local(name) => name, + } + } + + fn offset_ms(&self, instant_ms: i64) -> i64 { + match self { + Self::Utc => 0, + Self::Local(_) => { + let secs: libc::time_t = instant_ms.div_euclid(1_000); + // SAFETY: `tm` is plain data and `localtime_r` only writes through the pointers given. + let tm = unsafe { + let mut tm: libc::tm = std::mem::zeroed(); + libc::localtime_r(&secs, &mut tm); + tm + }; + tm.tm_gmtoff * 1_000 + } + } + } + + fn civil(&self, instant_ms: i64) -> Civil { + let local = instant_ms + self.offset_ms(instant_ms); + let days = local.div_euclid(DAY_MS); + let (_, month, day) = civil_from_days(days); + Civil { + days, + month, + day, + hour: local.rem_euclid(DAY_MS) / HOUR_MS, + } + } +} + +struct Civil { + days: i64, + month: i64, + day: i64, + hour: i64, +} + +fn hour_label(hour: i64) -> String { + let twelve = if hour % 12 == 0 { 12 } else { hour % 12 }; + format!("{twelve} {}", if hour < 12 { "AM" } else { "PM" }) +} + +/// `formatHourShort`, without the repeated-hour zone suffix. +pub fn format_hour_short(hour_start: &str, zone: &Zone) -> String { + match timestamp_ms(hour_start) { + Some(ms) => hour_label(zone.civil(ms).hour), + None => hour_start.to_owned(), + } +} + +pub fn format_date_time_short(instant: &str, zone: &Zone) -> String { + match timestamp_ms(instant) { + Some(ms) => { + let c = zone.civil(ms); + format!( + "{} {}, {}", + MONTHS[(c.month - 1) as usize], + c.day, + hour_label(c.hour) + ) + } + None => instant.to_owned(), + } +} + +pub fn format_relative_hour_short(hour_start: &str, relative_to: &str, zone: &Zone) -> String { + let (Some(instant), Some(reference)) = (timestamp_ms(hour_start), timestamp_ms(relative_to)) + else { + return format_date_time_short(hour_start, zone); + }; + let ago = zone.civil(reference).days - zone.civil(instant).days; + let hour = format_hour_short(hour_start, zone); + match ago { + 0 => format!("{hour} today"), + 1 => format!("{hour} yesterday"), + _ => format_date_time_short(hour_start, zone), + } +} + +pub fn enumerate_days(since: &str, until: &str) -> Vec { + let parse = |day: &str| { + let ms = timestamp_ms(&format!("{day}T00:00:00Z"))?; + Some(ms.div_euclid(DAY_MS)) + }; + match (parse(since), parse(until)) { + (Some(start), Some(end)) if end >= start => (start..=end).map(format_day).collect(), + _ => Vec::new(), + } +} + +pub fn enumerate_hour_starts(since: &str, until: &str) -> Vec { + match (timestamp_ms(since), timestamp_ms(until)) { + (Some(start), Some(end)) if end > start => (0..) + .map(|i| start + i * HOUR_MS) + .take_while(|t| *t < end) + .map(iso_from_ms) + .collect(), + _ => Vec::new(), + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Window { + pub since_day: String, + pub until_day: String, + pub time_zone: String, + pub hourly: bool, + pub since_time: Option, + pub until_time: Option, +} + +impl Window { + /// The `UsageSummaryInput` payload. + pub fn payload(&self, await_refresh: bool) -> Value { + let mut payload = json!({ + "sinceDay": self.since_day, + "untilDay": self.until_day, + "timeZone": self.time_zone, + "resolution": if self.hourly { "hour" } else { "day" }, + }); + if let (Some(since), Some(until)) = (&self.since_time, &self.until_time) { + payload["sinceTime"] = json!(since); + payload["untilTime"] = json!(until); + } + if await_refresh { + payload["awaitRefresh"] = json!(true); + } + payload + } +} + +/// `makeWindow`, usageFormat.ts. +pub fn make_window(days: u32, now_ms: i64, hourly: bool, zone: &Zone) -> Window { + let day_of = |ms| format_day(zone.civil(ms).days); + if hourly { + let until = now_ms.div_euclid(60_000) * 60_000; + let since = until - 24 * HOUR_MS; + return Window { + since_day: day_of(since), + until_day: day_of(until), + time_zone: zone.name().to_owned(), + hourly, + since_time: Some(iso_from_ms(since)), + until_time: Some(iso_from_ms(until)), + }; + } + let today = zone.civil(now_ms).days; + Window { + since_day: format_day(today - (i64::from(days) - 1)), + until_day: format_day(today), + time_zone: zone.name().to_owned(), + hourly, + since_time: None, + until_time: None, + } +} + +// Chart math, UsageProviderChart.tsx. + +pub const VIEW_WIDTH: f64 = 960.0; +pub const VIEW_HEIGHT: f64 = 260.0; +pub const TICK_COUNT: usize = 4; +pub const PLOT_TOP: f64 = 8.0; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Point { + pub x: f64, + pub y: f64, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Curve { + pub from: Point, + pub c1: Point, + pub c2: Point, + pub to: Point, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Column { + pub bands: Vec<(Provider, f64)>, + pub total: f64, +} + +pub fn build_period_columns( + periods: &[String], + totals: &[PeriodTotals], + metric: Metric, +) -> Vec { + periods + .iter() + .map(|period| { + let entry = totals.iter().find(|t| t.key == *period); + let bands: Vec<(Provider, f64)> = Provider::ORDER + .iter() + .map(|provider| { + let value = entry + .and_then(|e| e.provider(*provider)) + .map_or(0.0, |share| { + if metric == Metric::Tokens { + share.tokens + } else { + share.cost + } + }); + (*provider, value) + }) + .collect(); + let total = bands.iter().map(|(_, v)| v).sum(); + Column { bands, total } + }) + .collect() +} + +fn monotone_tangents(points: &[Point]) -> Vec { + let count = points.len(); + if count < 2 { + return vec![0.0]; + } + let slopes: Vec = points + .windows(2) + .map(|w| { + let dx = w[1].x - w[0].x; + if dx == 0.0 { + 0.0 + } else { + (w[1].y - w[0].y) / dx + } + }) + .collect(); + let mut tangents = vec![0.0; count]; + tangents[0] = slopes[0]; + tangents[count - 1] = slopes[count - 2]; + for i in 1..count - 1 { + let (previous, next) = (slopes[i - 1], slopes[i]); + tangents[i] = if previous * next <= 0.0 { + 0.0 + } else { + (previous + next) / 2.0 + }; + } + for i in 0..count - 1 { + let slope = slopes[i]; + if slope == 0.0 { + tangents[i] = 0.0; + tangents[i + 1] = 0.0; + continue; + } + let a = tangents[i] / slope; + let b = tangents[i + 1] / slope; + let magnitude = a * a + b * b; + if magnitude > 9.0 { + let scale = 3.0 / magnitude.sqrt(); + tangents[i] = scale * a * slope; + tangents[i + 1] = scale * b * slope; + } + } + tangents +} + +pub fn smooth_curve(points: &[Point]) -> Vec { + if points.len() < 2 { + return Vec::new(); + } + let tangents = monotone_tangents(points); + points + .windows(2) + .enumerate() + .map(|(i, w)| { + let dx = w[1].x - w[0].x; + Curve { + from: w[0], + c1: Point { + x: w[0].x + dx / 3.0, + y: w[0].y + tangents[i] * dx / 3.0, + }, + c2: Point { + x: w[1].x - dx / 3.0, + y: w[1].y - tangents[i + 1] * dx / 3.0, + }, + to: w[1], + } + }) + .collect() +} + +pub fn nice_scale(peak: f64, count: usize) -> (f64, Vec) { + if peak <= 0.0 { + return (0.0, vec![0.0]); + } + let raw_step = peak / count as f64; + let magnitude = 10f64.powf(raw_step.log10().floor()); + let normalized = raw_step / magnitude; + let factor = if normalized > 5.0 { + 10.0 + } else if normalized > 2.0 { + 5.0 + } else if normalized > 1.0 { + 2.0 + } else { + 1.0 + }; + let step = factor * magnitude; + let max = (peak / step).ceil() * step; + let mut ticks = Vec::new(); + let mut value = 0.0; + while value <= max + step * 1e-6 { + ticks.push(value); + value += step; + } + (max, ticks) +} + +pub fn value_to_y(value: f64, max: f64) -> f64 { + if max == 0.0 { + VIEW_HEIGHT + } else { + VIEW_HEIGHT - (value / max) * (VIEW_HEIGHT - PLOT_TOP) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Series { + pub provider: Provider, + pub total: f64, + pub curve: Vec, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Chart { + pub columns: Vec, + pub max: f64, + pub ticks: Vec, + pub step_x: f64, + pub series: Vec, +} + +pub fn build_chart( + periods: &[String], + totals: &[PeriodTotals], + metric: Metric, + providers: &[Provider], +) -> Chart { + let columns = build_period_columns(periods, totals, metric); + let peak = columns + .iter() + .flat_map(|c| c.bands.iter().map(|(_, v)| *v)) + .fold(0.0, f64::max); + let (max, ticks) = nice_scale(peak, TICK_COUNT); + let step_x = if periods.len() < 2 { + 0.0 + } else { + VIEW_WIDTH / (periods.len() - 1) as f64 + }; + let mut series: Vec = providers + .iter() + .map(|provider| { + let slot = provider.slot(); + let points: Vec = columns + .iter() + .enumerate() + .map(|(i, c)| Point { + x: i as f64 * step_x, + y: value_to_y(c.bands[slot].1, max), + }) + .collect(); + Series { + provider: *provider, + total: columns.iter().map(|c| c.bands[slot].1).sum(), + curve: smooth_curve(&points), + } + }) + .collect(); + series.sort_by(|a, b| by_desc(a.total, b.total)); + Chart { + columns, + max, + ticks, + step_x, + series, + } +} + +// Preferences, usagePagePreferences.ts. The web keeps them in localStorage; this app has no +// such store, so they live in a small JSON file under the app's home directory. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Prefs { + pub metric: Metric, + pub window_days: u32, +} + +impl Default for Prefs { + fn default() -> Self { + Self { + metric: Metric::Limits, + window_days: 30, + } + } +} + +const PREFS_FILE: &str = "usage-page-preferences.v1.json"; + +impl Prefs { + pub fn parse(text: &str) -> Option { + let value: Value = serde_json::from_str(text).ok()?; + let metric = match value.get("metric")?.as_str()? { + "cost" => Metric::Cost, + "tokens" => Metric::Tokens, + "limits" => Metric::Limits, + _ => return None, + }; + let window_days = match value.get("windowDays")?.as_u64()? { + n @ (1 | 7 | 30 | 90) => n as u32, + _ => return None, + }; + Some(Self { + metric, + window_days, + }) + } + + pub fn to_json(self) -> String { + json!({ + "metric": match self.metric { + Metric::Cost => "cost", + Metric::Tokens => "tokens", + Metric::Limits => "limits", + }, + "windowDays": self.window_days, + }) + .to_string() + } + + pub fn path() -> Option { + let home = std::env::var(crate::config::ENV_HOME).ok()?; + (!home.trim().is_empty()).then(|| Path::new(&home).join(PREFS_FILE)) + } + + pub fn load() -> Self { + Self::path() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|text| Self::parse(&text)) + .unwrap_or_default() + } + + pub fn save(self) { + if let Some(path) = Self::path() { + let _ = std::fs::write(path, self.to_json()); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bucket(overrides: impl FnOnce(&mut Value)) -> Value { + let mut value = json!({ + "day": "2026-08-07", + "provider": "claude", + "model": "claude-fable-5", + "totals": { + "uncachedInputTokens": 100, + "cachedInputTokens": 1000, + "cacheCreationTokens": 10, + "outputTokens": 50, + "reasoningTokens": 0, + }, + "costUsd": 10, + "cacheSavingsUsd": 2, + "costSource": "modelPriced", + "records": 5, + "unpricedRecords": 0, + "sessions": 1, + }); + overrides(&mut value); + value + } + + fn summary(buckets: Vec, sources: Vec<(&str, &str, u64)>, version: u64) -> Value { + json!({ + "contractVersion": version, + "readAt": "2026-08-07T00:00:00.000Z", + "timeZone": "UTC", + "sinceDay": "2026-08-01", + "untilDay": "2026-08-31", + "buckets": buckets, + "sources": sources.into_iter().map(|(provider, home, sessions)| json!({ + "fingerprint": { + "hostId": "mac", + "provider": provider, + "resolvedHomePath": home, + "volumeId": "vol-mac", + }, + "status": "ok", + "scannedFiles": 1, + "skippedFiles": 0, + "malformedRecords": 0, + "distinctSessions": sessions, + "message": null, + })).collect::>(), + "pricing": {"status": "fresh", "source": "litellm", "fetchedAt": null, "knownModels": 10}, + "scanDurationMs": 1, + }) + } + + fn merged(value: Value) -> Merged { + merge(&Summary::decode(&value).expect("decodes")) + } + + fn source( + provider: Provider, + status: SourceStatus, + malformed: f64, + message: Option<&str>, + ) -> Source { + Source { + host_id: "demo-host".into(), + provider, + home: "/demo/codex".into(), + volume_id: "demo-volume".into(), + status, + malformed, + distinct_sessions: 1.0, + message: message.map(str::to_owned), + has_action: false, + refreshing: false, + } + } + + fn malformed(count: f64) -> Source { + source( + Provider::Codex, + SourceStatus::Partial, + count, + Some(&malformed_warning(count)), + ) + } + + #[test] + fn formats_money_counts_tokens_and_percent() { + assert_eq!(format_usd(12_076.08), "$12,076.08"); + assert_eq!(format_usd(0.0), "$0.00"); + assert_eq!(format_count(3_687.0), "3,687"); + assert_eq!(format_tokens(101e9), "101B"); + assert_eq!(format_tokens(769e6), "769M"); + assert_eq!(format_tokens(1_500.0), "1.50K"); + assert_eq!(format_tokens(12_340.0), "12.3K"); + assert_eq!(format_tokens(999.0), "999"); + assert_eq!(format_tokens(2e12), "2T"); + assert_eq!(format_percent(0.529, 1), "52.9%"); + assert_eq!(format_percent(0.0004, 1), "<0.1%"); + assert_eq!(format_percent(0.0, 1), "0.0%"); + assert_eq!(format_day_short("2026-08-11"), "Aug 11"); + } + + #[test] + fn combines_malformed_warnings() { + let warning = |n| malformed_warning(n); + let other_host = |count, host: &str| Source { + host_id: host.into(), + ..malformed(count) + }; + assert_eq!( + source_messages(&[ + malformed(1.0), + Source { + provider: Provider::Claude, + home: "/demo/claude".into(), + ..malformed(11.0) + } + ]), + vec![warning(12.0)] + ); + let (first, second) = (malformed(1.0), other_host(11.0, "other-demo-host")); + assert_eq!( + source_messages(&[first.clone(), first.clone(), second.clone(), second.clone()]), + vec![warning(12.0)] + ); + assert_eq!(source_messages(&[first.clone(), first]), vec![warning(1.0)]); + let machine = Source { + volume_id: "other-demo-volume".into(), + ..malformed(11.0) + }; + assert_eq!( + source_messages(&[malformed(1.0), machine]), + vec![warning(12.0)] + ); + assert_eq!( + source_messages(&[malformed(1.0), malformed(11.0), malformed(1.0)]), + vec![warning(11.0)] + ); + } + + #[test] + fn keeps_read_failures_beside_the_combined_warning() { + let read_failure = "2 transcript path(s) could not be read; usage may be incomplete."; + let first = malformed(1.0); + let with_failure = Source { + message: Some(format!("{read_failure} {}", malformed_warning(1.0))), + ..first.clone() + }; + let plain = Source { + malformed: 0.0, + message: Some(read_failure.into()), + ..first.clone() + }; + let cursor = Source { + provider: Provider::Cursor, + status: SourceStatus::Ok, + malformed: 0.0, + message: Some("Cursor history is limited to the last 30 days.".into()), + ..first + }; + assert_eq!( + source_messages(&[with_failure, plain, cursor]), + vec![ + read_failure.to_owned(), + "Cursor history is limited to the last 30 days.".to_owned(), + malformed_warning(1.0), + ] + ); + } + + #[test] + fn missing_directories_keep_their_cached_diagnostics() { + let missing = Source { + host_id: "other-demo-host".into(), + status: SourceStatus::Missing, + malformed: 2.0, + distinct_sessions: 0.0, + message: Some(format!( + "No transcript directory on this environment. {}", + malformed_warning(2.0) + )), + ..malformed(2.0) + }; + assert_eq!( + source_messages(&[malformed(1.0), missing.clone(), missing]), + vec![ + "No transcript directory on this environment.".to_owned(), + malformed_warning(3.0), + ] + ); + } + + #[test] + fn omits_empty_missing_healthy_and_action_sources() { + let ok = |malformed, status| Source { + status, + malformed, + ..self::malformed(0.0) + }; + assert!( + source_messages(&[ + ok(0.0, SourceStatus::Missing), + ok(1.0, SourceStatus::Ok), + Source { + has_action: true, + ..ok(1.0, SourceStatus::Partial) + }, + ]) + .is_empty() + ); + assert!(source_messages(&[]).is_empty()); + } + + #[test] + fn builds_an_exact_minute_aligned_24_hour_request() { + let now = timestamp_ms("2026-08-11T12:37:42.123Z").expect("time"); + let window = make_window(1, now, true, &Zone::Utc); + assert_eq!( + window.since_time.as_deref(), + Some("2026-08-10T12:37:00.000Z") + ); + assert_eq!( + window.until_time.as_deref(), + Some("2026-08-11T12:37:00.000Z") + ); + assert_eq!(window.payload(false)["resolution"], "hour"); + assert_eq!(window.payload(true)["awaitRefresh"], true); + } + + #[test] + fn day_windows_end_today_and_span_the_requested_days() { + let now = timestamp_ms("2026-03-02T12:37:42.123Z").expect("time"); + let window = make_window(7, now, false, &Zone::Utc); + assert_eq!( + (window.since_day.as_str(), window.until_day.as_str()), + ("2026-02-24", "2026-03-02") + ); + assert_eq!(window.time_zone, "UTC"); + assert_eq!( + enumerate_days(&window.since_day, &window.until_day).len(), + 7 + ); + assert_eq!( + make_window(30, now, false, &Zone::Utc).since_day, + "2026-02-01" + ); + } + + #[test] + fn enumerates_hours_and_labels_them() { + let hours = enumerate_hour_starts("2026-08-10T12:37:00.000Z", "2026-08-11T12:37:00.000Z"); + assert_eq!(hours.len(), 24); + assert_eq!(hours[0], "2026-08-10T12:37:00.000Z"); + assert_eq!( + format_hour_short("2026-08-11T12:37:00.000Z", &Zone::Utc), + "12 PM" + ); + assert_eq!( + format_hour_short("2026-08-11T00:37:00.000Z", &Zone::Utc), + "12 AM" + ); + assert_eq!(format_hour_short("invalid", &Zone::Utc), "invalid"); + assert_eq!( + format_date_time_short("2026-08-11T13:00:00.000Z", &Zone::Utc), + "Aug 11, 1 PM" + ); + let now = "2026-08-11T20:00:00.000Z"; + assert_eq!( + format_relative_hour_short("2026-08-11T13:00:00.000Z", now, &Zone::Utc), + "1 PM today" + ); + assert_eq!( + format_relative_hour_short("2026-08-10T13:00:00.000Z", now, &Zone::Utc), + "1 PM yesterday" + ); + assert_eq!( + format_relative_hour_short("2026-08-08T13:00:00.000Z", now, &Zone::Utc), + "Aug 8, 1 PM" + ); + } + + #[test] + fn keeps_known_usage_when_newer_variants_cannot_be_decoded() { + let mut value = summary(vec![bucket(|_| {})], vec![("claude", "/a", 1)], 6); + value["buckets"].as_array_mut().expect("array").extend([ + bucket(|b| { + b["provider"] = json!("future-provider"); + b["costUsd"] = json!(100); + }), + bucket(|b| { + b["costSource"] = json!("future-pricing"); + b["costUsd"] = json!(200); + }), + ]); + let mut extra = value["sources"][0].clone(); + extra["fingerprint"]["provider"] = json!("future-provider"); + value["sources"].as_array_mut().expect("array").push(extra); + let decoded = Summary::decode(&value).expect("decodes"); + assert_eq!((decoded.buckets.len(), decoded.sources.len()), (1, 1)); + let merged = merge(&decoded); + assert_eq!(merged.cost, 10.0); + assert_eq!(merged.total_tokens, 1160.0); + assert_eq!(merged.mismatch, None); + } + + #[test] + fn trims_model_names_before_grouping() { + let value = summary( + vec![ + bucket(|b| { + b["provider"] = json!("codex"); + b["model"] = json!(" gpt-5 "); + }), + bucket(|b| { + b["provider"] = json!("codex"); + b["model"] = json!("gpt-5"); + }), + ], + vec![("codex", "/a", 1)], + 6, + ); + let merged = merged(value); + assert_eq!(merged.models.len(), 1); + assert_eq!(merged.models[0].model, "gpt-5"); + assert_eq!( + (merged.models[0].cost, merged.models[0].tokens), + (20.0, 2320.0) + ); + } + + #[test] + fn flags_contract_versions_outside_the_compatible_range() { + let behind = merged(summary(vec![bucket(|_| {})], vec![("claude", "/a", 1)], 3)); + assert_eq!(behind.mismatch, Some(Mismatch::ServerBehind)); + assert_eq!(behind.cost, 0.0); + let ahead = merged(summary(vec![bucket(|_| {})], vec![("claude", "/a", 1)], 7)); + assert_eq!(ahead.mismatch, Some(Mismatch::ClientBehind)); + let previous = merged(summary(vec![bucket(|_| {})], vec![("claude", "/a", 1)], 4)); + assert_eq!(previous.mismatch, None); + assert_eq!(previous.cost, 10.0); + } + + #[test] + fn marks_a_model_with_no_known_rates_as_unpriced() { + let value = summary( + vec![ + bucket(|b| b["costUsd"] = json!(75)), + bucket(|b| { + b["provider"] = json!("codex"); + b["model"] = json!("unknown-model"); + b["costUsd"] = json!(0); + b["costSource"] = json!("unpriced"); + b["unpricedRecords"] = json!(5); + }), + ], + vec![("claude", "/a/.claude", 1), ("codex", "/a/.codex", 1)], + 6, + ); + let merged = merged(value); + let unknown: Vec<&str> = merged + .models + .iter() + .filter(|m| m.cost_unknown()) + .map(|m| m.model.as_str()) + .collect(); + assert_eq!(unknown, ["unknown-model"]); + let model = &merged.models[1]; + assert_eq!(model_share(model, Metric::Cost), None); + assert_eq!(model_share(model, Metric::Tokens), Some(model.token_share)); + } + + #[test] + fn splits_cost_by_category_and_speed() { + let value = summary( + vec![ + bucket(|b| { + b["costUsd"] = json!(10); + b["categoryCostUsd"] = + json!({"input": 1, "cacheRead": 2, "cacheWrite": 3, "output": 4}); + b["fastCostUsd"] = json!(6); + b["speedPremiumUsd"] = json!(3); + }), + bucket(|b| { + b["provider"] = json!("codex"); + b["model"] = json!("unknown-model"); + b["costUsd"] = json!(0); + b["costSource"] = json!("unpriced"); + b["unpricedRecords"] = json!(5); + }), + bucket(|b| { + b["provider"] = json!("codex"); + b["model"] = json!("partly-reported"); + b["costUsd"] = json!(0); + b["unpricedRecords"] = json!(4); + }), + ], + vec![("claude", "/a/.claude", 1), ("codex", "/a/.codex", 1)], + 6, + ); + let merged = merged(value); + assert_eq!( + merged.category_cost, + CategoryCost { + input: 1.0, + cache_read: 2.0, + cache_write: 3.0, + output: 4.0, + unsplit: 0.0 + } + ); + assert_eq!( + merged.speed_cost, + SpeedCost { + standard: 4.0, + fast: 6.0, + ultrafast: 0.0, + premium: 3.0 + } + ); + assert!((merged.unpriced_share - 9.0 / 15.0).abs() < 1e-12); + } + + #[test] + fn unsplit_cost_is_the_remainder_when_a_server_sends_no_categories() { + let value = summary( + vec![ + bucket(|b| { + b["categoryCostUsd"] = + json!({"input": 1, "cacheRead": 2, "cacheWrite": 3, "output": 4}); + }), + bucket(|b| b["costUsd"] = json!(5)), + ], + vec![("claude", "/a", 1)], + 6, + ); + let merged = merged(value); + assert_eq!(merged.category_cost.unsplit, 5.0); + assert_eq!(merged.speed_cost.standard, 15.0); + } + + #[test] + fn totals_sessions_from_directory_counts_not_bucket_sums() { + let value = summary( + vec![ + bucket(|b| b["day"] = json!("2026-08-06")), + bucket(|b| b["day"] = json!("2026-08-07")), + ], + vec![("claude", "/a/.claude", 1)], + 6, + ); + let merged = merged(value); + assert_eq!(merged.sessions, 1.0); + assert_eq!(merged.providers[0].sessions, 1.0); + assert_eq!(merged.daily.len(), 2); + } + + #[test] + fn buckets_from_unclaimed_directories_do_not_count() { + let value = summary( + vec![ + bucket(|b| b["sourcePath"] = json!("/a/.claude")), + bucket(|b| b["sourcePath"] = json!("/elsewhere")), + ], + vec![("claude", "/a/.claude", 1)], + 6, + ); + assert_eq!(merged(value).cost, 10.0); + } + + #[test] + fn sorts_models_by_cost_then_tokens_and_by_tokens_then_cost() { + let value = summary( + vec![ + bucket(|b| { + b["model"] = json!("cheap-big"); + b["costUsd"] = json!(1); + b["totals"]["outputTokens"] = json!(1_000_000); + }), + bucket(|b| { + b["model"] = json!("pricey-small"); + b["costUsd"] = json!(5); + }), + ], + vec![("claude", "/a", 1)], + 6, + ); + let merged = merged(value); + let names = |models: &[ModelTotals]| -> Vec { + models.iter().map(|m| m.model.clone()).collect() + }; + assert_eq!(names(&merged.models), ["pricey-small", "cheap-big"]); + assert_eq!( + names(&sort_models_by_tokens(&merged.models)), + ["cheap-big", "pricey-small"] + ); + } + + #[test] + fn segments_drop_sub_cent_remainders() { + let cost = CategoryCost { + input: 1.0, + unsplit: 0.004, + ..CategoryCost::default() + }; + let segments = cost_type_segments(&cost); + assert_eq!(segments[4].value, 0.0); + assert_eq!(segments[0].ink, 60); + let tokens = token_type_segments(&Tokens { + output: 3.0, + ..Tokens::default() + }); + assert_eq!( + (tokens[3].label, tokens[3].value, tokens[3].ink), + ("Output", 3.0, 100) + ); + assert_eq!(speed_cost_segments(&SpeedCost::default())[1].ink, 66); + } + + fn day_totals(day: &str, shares: &[(Provider, f64, f64)]) -> PeriodTotals { + PeriodTotals { + key: day.into(), + cost: shares.iter().map(|s| s.1).sum(), + tokens: shares.iter().map(|s| s.2).sum(), + by_provider: shares + .iter() + .map(|(p, cost, tokens)| { + ( + *p, + Share { + cost: *cost, + tokens: *tokens, + }, + ) + }) + .collect(), + } + } + + #[test] + fn period_columns_keep_band_values_absolute() { + let days: Vec = ["2026-08-01", "2026-08-02", "2026-08-03"] + .map(String::from) + .into(); + let totals = [ + day_totals( + "2026-08-01", + &[ + (Provider::Codex, 10.0, 100.0), + (Provider::Claude, 20.0, 200.0), + (Provider::Pi, 5.0, 50.0), + (Provider::Muse, 7.0, 70.0), + ], + ), + day_totals("2026-08-03", &[(Provider::Claude, 5.0, 50.0)]), + ]; + let columns = build_period_columns(&days, &totals, Metric::Cost); + let values: Vec = columns[0].bands.iter().map(|b| b.1).collect(); + assert_eq!(values, [10.0, 20.0, 0.0, 0.0, 0.0, 0.0, 5.0, 7.0]); + assert_eq!(columns[0].bands[1].0, Provider::Claude); + assert_eq!(columns[0].total, 42.0); + assert_eq!(columns[1].total, 0.0); + assert_eq!( + build_period_columns(&days, &totals, Metric::Tokens)[2].total, + 50.0 + ); + } + + #[test] + fn nice_scale_picks_round_steps() { + assert_eq!(nice_scale(0.0, 4), (0.0, vec![0.0])); + assert_eq!(nice_scale(9.0, 4), (10.0, vec![0.0, 5.0, 10.0])); + let (max, ticks) = nice_scale(7.9e9, 4); + assert_eq!(max, 8e9); + assert_eq!(ticks, [0.0, 2e9, 4e9, 6e9, 8e9]); + assert_eq!(nice_scale(100.0, 4).0, 100.0); + assert_eq!(nice_scale(1.0, 4).1.len(), 3); + } + + #[test] + fn curves_stay_monotone_and_flat_runs_stay_flat() { + let point = |x, y| Point { x, y }; + let flat = smooth_curve(&[point(0.0, 5.0), point(1.0, 5.0), point(2.0, 5.0)]); + assert!(flat.iter().all(|c| c.c1.y == 5.0 && c.c2.y == 5.0)); + let rising = smooth_curve(&[ + point(0.0, 10.0), + point(1.0, 5.0), + point(2.0, 0.0), + point(3.0, 0.0), + ]); + for c in &rising { + let (low, high) = (c.from.y.min(c.to.y), c.from.y.max(c.to.y)); + assert!(c.c1.y >= low - 1e-9 && c.c1.y <= high + 1e-9); + assert!(c.c2.y >= low - 1e-9 && c.c2.y <= high + 1e-9); + } + assert!(smooth_curve(&[point(0.0, 1.0)]).is_empty()); + } + + #[test] + fn chart_scales_to_the_largest_single_series_value() { + let days: Vec = ["2026-08-01", "2026-08-02"].map(String::from).into(); + let totals = [ + day_totals( + "2026-08-01", + &[(Provider::Codex, 0.0, 1e9), (Provider::Claude, 0.0, 3e9)], + ), + day_totals("2026-08-02", &[(Provider::Claude, 0.0, 2e9)]), + ]; + let chart = build_chart( + &days, + &totals, + Metric::Tokens, + &[Provider::Codex, Provider::Claude], + ); + assert_eq!(chart.max, 3e9); + assert_eq!(chart.step_x, VIEW_WIDTH); + assert_eq!(chart.series[0].provider, Provider::Claude); + assert_eq!(chart.series[0].curve[0].from.y, value_to_y(3e9, 3e9)); + assert_eq!(value_to_y(0.0, 0.0), VIEW_HEIGHT); + assert_eq!(value_to_y(3e9, 3e9), PLOT_TOP); + } + + #[test] + fn active_providers_follow_the_fixed_order() { + let value = summary( + vec![bucket(|_| {}), bucket(|b| b["provider"] = json!("codex"))], + vec![("claude", "/a", 1), ("codex", "/b", 1)], + 6, + ); + assert_eq!( + active_providers(&merged(value)), + [Provider::Codex, Provider::Claude] + ); + } + + #[test] + fn preferences_default_to_limits_over_30_days_and_reject_bad_files() { + assert_eq!( + Prefs::default(), + Prefs { + metric: Metric::Limits, + window_days: 30 + } + ); + let saved = Prefs { + metric: Metric::Cost, + window_days: 7, + }; + assert_eq!(Prefs::parse(&saved.to_json()), Some(saved)); + assert_eq!(Prefs::parse(r#"{"metric":"cost","windowDays":14}"#), None); + assert_eq!(Prefs::parse(r#"{"metric":"nope","windowDays":7}"#), None); + assert_eq!(Prefs::parse("not json"), None); + } + + #[test] + fn decoding_rejects_a_summary_missing_required_fields() { + let mut value = summary(vec![], vec![], 6); + assert!(Summary::decode(&value).is_some()); + value["pricing"]["status"] = json!("weird"); + assert!(Summary::decode(&value).is_none()); + assert!(Summary::decode(&json!({})).is_none()); + } + + #[test] + fn contract_mismatch_messages_name_the_environment() { + assert_eq!( + contract_mismatch_message("Local", Mismatch::ServerBehind), + "Local runs an older server version and is excluded from totals." + ); + assert_eq!( + contract_mismatch_message("Local", Mismatch::ClientBehind), + "This client is older than the server on Local; its usage is excluded from totals." + ); + } +} From 465bdb3e4b0b01c12c0b4e7cfb475f2bfff8f644 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 8 Nov 2026 03:42:43 -0500 Subject: [PATCH 58/82] feat(desktop-gpui): usage modal limits tab Limits is the web's default usage view, and the GPUI modal opened on a placeholder there. It now ports UsageLimits and the pooled view: pool sections, window bars with pace and refill marks, the hover popover with reset credits behind a confirm, notices, ChatGPT sharing and Cursor keychain rows, and a throttled refresh on open. --- apps/desktop-gpui/src/assets.rs | 5 + apps/desktop-gpui/src/connection.rs | 15 +- apps/desktop-gpui/src/main.rs | 1 + apps/desktop-gpui/src/providers.rs | 93 +- apps/desktop-gpui/src/rpc.rs | 10 + apps/desktop-gpui/src/ui.rs | 12 +- apps/desktop-gpui/src/ui/usage_dialog.rs | 250 +-- .../src/ui/usage_dialog/limits.rs | 1552 +++++++++++++++++ apps/desktop-gpui/src/usage.rs | 131 +- apps/desktop-gpui/src/usage_data.rs | 35 +- apps/desktop-gpui/src/usage_limits.rs | 1023 +++++++++++ 11 files changed, 3002 insertions(+), 125 deletions(-) create mode 100644 apps/desktop-gpui/src/ui/usage_dialog/limits.rs create mode 100644 apps/desktop-gpui/src/usage_limits.rs diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 38f407d66fe2..5262b17bd566 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -37,6 +37,11 @@ gpui_kit_assets::icon_assets!( ChevronUp, CircleCheck, TriangleAlert, + Gauge, + TrendingUp, + TrendingDown, + Ticket, + ExternalLink, Copy, LoaderCircle, Clock, diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 89f89ba2ab56..39b5e59a20b0 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -8,10 +8,11 @@ use crate::providers::Catalog; use crate::rpc::{ AttachmentsCreateUploadUrl, AttachmentsDelete, Event, GitRunStackedAction, OrchestrationDispatchCommand, OrchestrationLaunchThread, PING_FRAME, ProjectsMutate, - ReviewGetDiffPreview, ServerGetConfig, ServerGetUsageSummary, ServerRefreshUsageRates, - ServerUpdateSettings, Session, ShellOpenInEditor, SubscribeShell, SubscribeThread, - SubscribeVcsStatus, TerminalAttach, TerminalClose, TerminalResize, TerminalWrite, VcsCreateRef, - VcsListRefs, VcsPull, VcsRefreshStatus, VcsSwitchRef, + ProviderConsumeResetCredit, ReviewGetDiffPreview, ServerGetConfig, ServerGetUsageSummary, + ServerRefreshProviders, ServerRefreshUsageRates, ServerUpdateSettings, Session, + ShellOpenInEditor, SubscribeShell, SubscribeThread, SubscribeVcsStatus, TerminalAttach, + TerminalClose, TerminalResize, TerminalWrite, VcsCreateRef, VcsListRefs, VcsPull, + VcsRefreshStatus, VcsSwitchRef, }; use crate::shell::ShellState; use crate::status::{CallEvent, Status, StatusSender, publish}; @@ -127,6 +128,8 @@ pub enum Call { DeleteAttachment, GetUsageSummary, RefreshUsageRates, + RefreshProviders, + ConsumeResetCredit, } pub type ActionSender = async_channel::Sender; @@ -169,6 +172,8 @@ impl Call { Self::DeleteAttachment => "delete the attachment", Self::GetUsageSummary => "load usage", Self::RefreshUsageRates => "refresh usage rates", + Self::RefreshProviders => "refresh the provider limits", + Self::ConsumeResetCredit => "use the reset credit", } } @@ -191,6 +196,8 @@ impl Call { Self::DeleteAttachment => session.request::(payload), Self::GetUsageSummary => session.request::(payload), Self::RefreshUsageRates => session.request::(payload), + Self::RefreshProviders => session.request::(payload), + Self::ConsumeResetCredit => session.request::(payload), } } } diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 4af7fdb55013..fb4bf866e7a2 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -36,6 +36,7 @@ mod ui; mod upload; mod usage; mod usage_data; +mod usage_limits; mod wire; use assets::AppAssets; diff --git a/apps/desktop-gpui/src/providers.rs b/apps/desktop-gpui/src/providers.rs index 7fcd9a825739..aea006464814 100644 --- a/apps/desktop-gpui/src/providers.rs +++ b/apps/desktop-gpui/src/providers.rs @@ -5,6 +5,7 @@ use crate::strip::Defaults; use crate::usage::Limits; +use crate::usage_limits::Native; use serde::de::DeserializeOwned; use serde::{Deserialize, Deserializer}; use serde_json::Value; @@ -167,6 +168,12 @@ struct Provider { accent_color: Option, status: String, #[serde(default)] + enabled: bool, + #[serde(default)] + installed: bool, + #[serde(default)] + auth: Auth, + #[serde(default)] availability: Option, #[serde(default)] message: Option, @@ -177,6 +184,22 @@ struct Provider { usage_limits: Option, } +/// The slice of `ServerProviderAuth` the limits read. +#[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct Auth { + #[serde(default)] + status: String, + #[serde(default)] + label: Option, + #[serde(default)] + email: Option, + #[serde(default)] + workspace_id: Option, + #[serde(default)] + subscription_sharing: Option, +} + #[derive(Clone, Debug, Default, Deserialize, PartialEq, Eq)] struct InstanceConfig { #[serde(default)] @@ -488,6 +511,18 @@ impl Catalog { .and_then(|provider| Limits::decode(provider.usage_limits.as_ref()?)) } + /// Each provider as the limits tab reads it. + pub fn natives(&self) -> Vec { + self.providers.iter().map(native).collect() + } + + /// The Cursor enable prompt needs a ready Cursor instance to show beside. + pub fn cursor_ready(&self) -> bool { + self.providers + .iter() + .any(|provider| provider.driver == "cursor" && provider.status == "ready") + } + /// A provider instance's icon, for the sidebar's thread cards. pub fn instance(&self, instance_id: &str) -> Option { let entries = self.entries(None); @@ -814,6 +849,45 @@ fn default_model(provider: &Provider) -> String { }) } +/// The providers of a `server.refreshProviders` reply, `{providers}`. +pub fn natives_from_reply(value: Value) -> Option> { + #[derive(Deserialize)] + struct Reply { + #[serde(default, deserialize_with = "lenient")] + providers: Vec, + } + let reply: Reply = serde_json::from_value(value).ok()?; + Some(reply.providers.iter().map(native).collect()) +} + +fn native(provider: &Provider) -> Native { + Native { + instance_id: provider.instance_id.clone(), + driver: provider.driver.clone(), + display_name: provider.display_name.clone(), + accent_color: provider.accent_color.clone(), + status: provider.status.clone(), + enabled: provider.enabled, + installed: provider.installed, + available: provider.availability.as_deref() != Some("unavailable"), + authenticated: provider.auth.status == "authenticated", + email: provider.auth.email.clone(), + plan: provider.auth.label.clone(), + workspace_id: provider.auth.workspace_id.clone(), + sharing: provider.auth.subscription_sharing == Some(true), + limits: provider.usage_limits.as_ref().and_then(Limits::decode), + } +} + +/// The label of a driver this client knows, else the driver as reported. +pub fn driver_label(driver: &str) -> String { + match driver { + "antigravity" | "codex" | "claudeAgent" | "cursor" | "grok" | "fx" | "kiro" | "muse" + | "acpRegistry" | "pi" | "opencode" => kind_label(driver), + other => other.to_owned(), + } +} + fn kind_label(driver: &str) -> String { match driver { "antigravity" => "Antigravity", @@ -878,7 +952,12 @@ fn display_name(provider: &Provider) -> String { } fn accent(provider: &Provider) -> Option { - let color = provider.accent_color.as_deref()?.trim(); + normalize_accent(provider.accent_color.as_deref()?) +} + +/// `normalizeProviderAccentColor`: a trimmed `#RRGGBB`. +pub fn normalize_accent(color: &str) -> Option { + let color = color.trim(); let hex = color.strip_prefix('#')?; if hex.len() != 6 || !hex.bytes().all(|byte| byte.is_ascii_hexdigit()) { return None; @@ -926,9 +1005,13 @@ fn mark(entries: &[Entry], entry: &Entry) -> Mark { } fn badge(provider: &Provider, display_name: &str) -> Badge { + accent_badge(display_name, accent(provider)) +} + +pub fn accent_badge(display_name: &str, accent: Option) -> Badge { Badge { initials: initials(display_name), - accent: accent(provider).map(|color| (color, accent_foreground(color))), + accent: accent.map(|color| (color, accent_foreground(color))), } } @@ -1509,6 +1592,9 @@ mod tests { display_name: display.map(str::to_owned), accent_color: None, status: "ready".into(), + enabled: true, + installed: true, + auth: Auth::default(), availability: None, message: None, models: Vec::new(), @@ -1532,6 +1618,9 @@ mod tests { display_name: None, accent_color: None, status: status.into(), + enabled: true, + installed: true, + auth: Auth::default(), availability: None, message: message.map(str::to_owned), models: Vec::new(), diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs index bed9c1f13e12..93d54e19731c 100644 --- a/apps/desktop-gpui/src/rpc.rs +++ b/apps/desktop-gpui/src/rpc.rs @@ -222,6 +222,16 @@ untyped_method!( ServerRefreshUsageRates, "server.refreshUsageRates" ); +untyped_method!( + /// `server.refreshProviders`: re-probes the providers and replies `{providers}`. + ServerRefreshProviders, + "server.refreshProviders" +); +untyped_method!( + /// `provider.consumeResetCredit`: spends one reset credit of an instance. + ProviderConsumeResetCredit, + "provider.consumeResetCredit" +); /// `git.runStackedAction`, a stream of progress events ending with the action's result. pub struct GitRunStackedAction; diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 3e6afe7fd04e..28bcce154e46 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -496,6 +496,8 @@ impl MainView { return cx.notify(); } if let Status::Providers(catalog) = status { + self.usage + .update(cx, |usage, cx| usage.set_providers(&catalog, cx)); self.providers = Some(catalog); return cx.notify(); } @@ -571,8 +573,14 @@ impl MainView { Status::Failed(_) => usage_dialog::Access::Offline, _ => usage_dialog::Access::Ready, }; - self.usage - .update(cx, |usage, cx| usage.set_access(access, cx)); + let environment = match &status { + Status::Connected { label, .. } => strip::environment_label(label), + _ => String::new(), + }; + self.usage.update(cx, |usage, cx| { + usage.set_environment(environment); + usage.set_access(access, cx); + }); self.status = status; // Thread activity reorders recency on every delta, so the default must not follow it. if self.selected_project.is_none() { diff --git a/apps/desktop-gpui/src/ui/usage_dialog.rs b/apps/desktop-gpui/src/ui/usage_dialog.rs index cfd1019dceef..b3b61e91fbcb 100644 --- a/apps/desktop-gpui/src/ui/usage_dialog.rs +++ b/apps/desktop-gpui/src/ui/usage_dialog.rs @@ -1,5 +1,7 @@ //! The web's Usage page (`UsagePage.tsx`) as the `/usage` route modal (`AppRouteModal.tsx`). +mod limits; + use super::*; use crate::usage_data::{ self as data, Merged, Metric, Prefs, Provider, Summary, Tint, Window as Span, Zone, @@ -8,6 +10,7 @@ use gpui::{ ClickEvent, ElementId, FocusHandle, KeyBinding, PathBuilder, ScrollHandle, Size, fill, point, radians, relative, size, }; +use limits::LimitsState; use serde_json::{Value, json}; use std::time::{SystemTime, UNIX_EPOCH}; @@ -78,6 +81,11 @@ enum Pending { Rates { generation: u64, }, + Limits, + Redeem { + key: (String, String), + }, + EnableCursor, } struct Shown { @@ -129,6 +137,7 @@ pub struct UsageDialog { scroll: ScrollHandle, next_token: u64, pending: HashMap, + limits: LimitsState, } fn now_ms() -> i64 { @@ -163,6 +172,7 @@ impl UsageDialog { scroll: ScrollHandle::new(), next_token: TOKEN_BASE, pending: HashMap::new(), + limits: LimitsState::default(), } } @@ -175,6 +185,7 @@ impl UsageDialog { self.span = self.make_span(); self.hover = None; self.breakdown_time = false; + self.limits.now = now_ms(); window.focus(&self.focus, cx); self.ensure_loaded(); cx.notify(); @@ -232,7 +243,11 @@ impl UsageDialog { /// Fetches when nothing is shown, the shown window is another one, or the data is old. fn ensure_loaded(&mut self) { - if !self.can_read() || self.prefs.metric == Metric::Limits || self.stage != Stage::Idle { + if self.prefs.metric == Metric::Limits { + self.refresh_limits(true); + return; + } + if !self.can_read() || self.stage != Stage::Idle { return; } let fresh = self @@ -268,6 +283,9 @@ impl UsageDialog { return; }; match pending { + Pending::Limits => self.limits_settled(event), + Pending::Redeem { key } => self.redeem_settled(key, event), + Pending::EnableCursor => self.enable_settled(event), Pending::Rates { generation } => { if generation == self.generation { self.start_query(); @@ -331,12 +349,20 @@ impl UsageDialog { self.prefs.metric = metric; self.prefs.save(); self.hover = None; + if metric == Metric::Limits { + self.limits.now = now_ms(); + } self.ensure_loaded(); cx.notify(); } fn refresh(&mut self, cx: &mut Context) { - if self.manual || !self.can_read() || self.prefs.metric == Metric::Limits { + if self.prefs.metric == Metric::Limits { + self.refresh_limits(false); + cx.notify(); + return; + } + if self.manual || !self.can_read() { return; } self.manual = true; @@ -543,6 +569,7 @@ impl Render for UsageDialog { let (border, background) = (theme.border, theme.background); let header = self.header(width, cx); let body = self.body(vw, cx); + let confirm = self.reset_credit_dialog(cx); div() .absolute() .inset_0() @@ -600,6 +627,7 @@ impl Render for UsageDialog { .child(body), ), ) + .children(confirm) .into_any_element() } } @@ -641,8 +669,12 @@ impl UsageDialog { cx, ) })); - let refreshing = self.manual; - let can_refresh = !refreshing && (limits || self.can_read()); + let refreshing = if limits { + self.limits.refreshing() + } else { + self.manual + }; + let can_refresh = !refreshing && self.can_read(); let icon = Icon::new(Icons::RefreshCw).size(px(14.)); let spinner = if refreshing { icon.with_animation( @@ -745,7 +777,7 @@ impl UsageDialog { .child(format!("Connect an environment to see {noun}.")) .into_any_element() } else if limits { - self.limits_body(cx) + self.limits_body(vw, cx) } else if self.access == Access::Offline { div() .py_12() @@ -772,14 +804,6 @@ impl UsageDialog { .into_any_element() } - fn limits_body(&self, cx: &App) -> AnyElement { - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("Limits are not available here yet.") - .into_any_element() - } - fn skeleton(&self, vw: f32, cx: &App) -> AnyElement { let block = |w: Option, h: f32| skeleton_block(w, h, false, cx); let wide = vw >= 1024.; @@ -995,101 +1019,116 @@ impl UsageDialog { data::format_percent(merged.unpriced_share, 1) ) }); - let rows = active.iter().map(|provider| { - let provider = *provider; - let totals = merged.providers.iter().find(|t| t.provider == provider); - let share = totals.map_or(0.0, |t| { - if metric == Metric::Cost { - t.cost_share + let mut rows: Vec = active + .iter() + .map(|provider| { + let provider = *provider; + let totals = merged.providers.iter().find(|t| t.provider == provider); + let share = totals.map_or(0.0, |t| { + if metric == Metric::Cost { + t.cost_share + } else { + t.token_share + } + }); + let count = totals.map_or(0.0, |t| t.sessions); + let loading = dim.provider(provider); + let awaiting = loading && !with_data.contains(&provider); + let (cost, tokens) = totals.map_or((0.0, 0.0), |t| (t.cost, t.tokens)); + let value = if awaiting { + "-".to_owned() + } else if metric == Metric::Cost { + data::format_usd(cost) } else { - t.token_share - } - }); - let count = totals.map_or(0.0, |t| t.sessions); - let loading = dim.provider(provider); - let awaiting = loading && !with_data.contains(&provider); - let (cost, tokens) = totals.map_or((0.0, 0.0), |t| (t.cost, t.tokens)); - let value = if awaiting { - "-".to_owned() - } else if metric == Metric::Cost { - data::format_usd(cost) - } else { - data::format_tokens(tokens) - }; - let line = if metric == Metric::Cost { - format!( - "{} of cost \u{b7} {} tokens", - data::format_percent(share, 1), data::format_tokens(tokens) - ) - } else { - format!( - "{} of tokens \u{b7} {}", - data::format_percent(share, 1), - data::format_usd(cost) - ) + }; + let line = if metric == Metric::Cost { + format!( + "{} of cost \u{b7} {} tokens", + data::format_percent(share, 1), + data::format_tokens(tokens) + ) + } else { + format!( + "{} of tokens \u{b7} {}", + data::format_percent(share, 1), + data::format_usd(cost) + ) + }; + let noun = if count == 1.0 { "session" } else { "sessions" }; + v_flex() + .gap_1() + .child( + h_flex() + .items_baseline() + .justify_between() + .gap_4() + .child( + h_flex() + .min_w_0() + .items_center() + .gap_2() + .text_sm() + .child( + div() + .size(px(8.)) + .flex_shrink_0() + .rounded_full() + .bg(tint(provider, cx)), + ) + .child(mark(provider, 16., cx)) + .child( + h_flex() + .min_w_0() + .items_baseline() + .gap(px(6.)) + .child(div().truncate().child(provider.label())) + .child( + div() + .flex_shrink_0() + .whitespace_nowrap() + .text_size(px(11.)) + .text_color(muted) + .opacity(dimmed(loading)) + .when(awaiting, |d| d.invisible()) + .child(format!( + "{} {noun}", + data::format_count(count) + )), + ), + ), + ) + .child( + div() + .flex_shrink_0() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .opacity(dimmed(loading)) + .child(value), + ), + ) + .child( + div() + .text_xs() + .text_color(muted) + .opacity(dimmed(loading)) + .when(awaiting, |d| d.invisible()) + .child(line), + ) + .into_any_element() + }) + .collect(); + if let Some(row) = self.cursor_enable_row(cx) { + let at = |provider: Provider| { + active + .iter() + .position(|p| *p == provider) + .map_or(-1, |i| i as isize) }; - let noun = if count == 1.0 { "session" } else { "sessions" }; - v_flex() - .gap_1() - .child( - h_flex() - .items_baseline() - .justify_between() - .gap_4() - .child( - h_flex() - .min_w_0() - .items_center() - .gap_2() - .text_sm() - .child( - div() - .size(px(8.)) - .flex_shrink_0() - .rounded_full() - .bg(tint(provider, cx)), - ) - .child(mark(provider, 16., cx)) - .child( - h_flex() - .min_w_0() - .items_baseline() - .gap(px(6.)) - .child(div().truncate().child(provider.label())) - .child( - div() - .flex_shrink_0() - .whitespace_nowrap() - .text_size(px(11.)) - .text_color(muted) - .opacity(dimmed(loading)) - .when(awaiting, |d| d.invisible()) - .child(format!( - "{} {noun}", - data::format_count(count) - )), - ), - ), - ) - .child( - div() - .flex_shrink_0() - .text_sm() - .font_weight(FontWeight::MEDIUM) - .opacity(dimmed(loading)) - .child(value), - ), - ) - .child( - div() - .text_xs() - .text_color(muted) - .opacity(dimmed(loading)) - .when(awaiting, |d| d.invisible()) - .child(line), - ) - }); + let index = (at(Provider::Codex).max(at(Provider::Claude)) + 1) as usize; + rows.insert(index.min(rows.len()), row); + } + let chatgpt = self.chatgpt_sharing().then(|| self.chatgpt_row(cx)); let left = v_flex() .min_w_0() .gap(px(20.)) @@ -1125,6 +1164,7 @@ impl UsageDialog { })), ), ) + .children(chatgpt) .children(rows); let hourly = span.hourly; diff --git a/apps/desktop-gpui/src/ui/usage_dialog/limits.rs b/apps/desktop-gpui/src/ui/usage_dialog/limits.rs new file mode 100644 index 000000000000..9101d6d51c45 --- /dev/null +++ b/apps/desktop-gpui/src/ui/usage_dialog/limits.rs @@ -0,0 +1,1552 @@ +//! The Limits tab: `UsageLimits.tsx` and `UsageLimitsPooled.tsx` over `usage_limits.rs`. + +use super::*; +use crate::providers::{accent_badge, driver_label, normalize_accent}; +use crate::usage::{Pace, Window as LimitWindow}; +use crate::usage_limits::{ + self as limits, Account, CHATGPT_USAGE_URL, Link, Native, Pool, PoolWindow, + cursor_window_details, +}; +use gpui::{Anchor, StyleRefinement, WeakEntity, pattern_slash}; +use gpui_component::hover_card::HoverCard; + +/// `CURSOR_KEYCHAIN_COPY`. +pub(super) const CURSOR_KEYCHAIN_COPY: &str = + "Requires access to your Cursor login in macOS Keychain."; +/// `@2xl/pool`. +const WIDE_BAR: f32 = 672.; + +/// The segment whose reset credit the confirm dialog spends. +#[derive(Clone, PartialEq, Eq)] +pub(super) struct Target { + key: String, + window: String, + instance: String, + name: String, +} + +#[derive(Default)] +pub(super) struct LimitsState { + natives: Vec, + environment: String, + cursor_ready: bool, + pub(super) now: i64, + pending: bool, + queued: bool, + refresh_after: i64, + reveal: bool, + confirm: Option, + busy: Option<(String, String)>, + status: HashMap<(String, String), String>, + enabling: bool, +} + +impl LimitsState { + pub(super) fn refreshing(&self) -> bool { + self.pending + } +} + +fn outcome_text(outcome: &str) -> &'static str { + match outcome { + "reset" => "Reset applied. Your windows have cleared.", + "nothingToReset" => "Nothing to reset right now.", + "noCredit" => "No reset credit left.", + "alreadyRedeemed" => "That credit was already redeemed.", + _ => "Could not use the reset credit.", + } +} + +impl UsageDialog { + pub fn set_providers(&mut self, catalog: &Catalog, cx: &mut Context) { + self.limits.natives = catalog.natives(); + self.limits.cursor_ready = catalog.cursor_ready(); + if self.open && self.prefs.metric == Metric::Limits { + self.limits.now = now_ms(); + } + cx.notify(); + } + + pub fn set_environment(&mut self, label: String) { + self.limits.environment = label; + } + + /// `refreshLimits`: automatic ones wait out the five minute throttle, manual ones join a + /// refresh in flight and run again when it settles. + pub(super) fn refresh_limits(&mut self, automatic: bool) { + if !self.can_read() { + return; + } + let state = &mut self.limits; + if state.pending { + state.queued |= !automatic; + return; + } + let now = now_ms(); + if automatic && now < state.refresh_after { + return; + } + state.pending = true; + state.refresh_after = now + 5 * 60_000; + self.call(Call::RefreshProviders, json!({}), Pending::Limits); + } + + pub(super) fn limits_settled(&mut self, event: CallEvent) { + self.limits.pending = false; + self.limits.now = now_ms(); + if let CallEvent::Done(value) = event + && let Some(natives) = crate::providers::natives_from_reply(value) + { + self.limits.natives = natives; + } + if std::mem::take(&mut self.limits.queued) { + self.refresh_limits(false); + } + } + + pub(super) fn redeem_settled(&mut self, key: (String, String), event: CallEvent) { + self.limits.busy = None; + let text = match event { + CallEvent::Done(value) => value + .get("warning") + .and_then(Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| { + outcome_text(value.get("outcome").and_then(Value::as_str).unwrap_or("")) + .to_owned() + }), + CallEvent::Failed(message) if !message.is_empty() => message, + _ => "Could not use the reset credit.".to_owned(), + }; + self.limits.status.insert(key, text); + } + + pub(super) fn enable_settled(&mut self, event: CallEvent) { + self.limits.enabling = false; + if matches!(event, CallEvent::Done(_)) { + self.generation += 1; + self.stage = Stage::Idle; + self.start_query(); + self.refresh_limits(false); + } + } + + fn enable_cursor(&mut self, cx: &mut Context) { + if self.limits.enabling { + return; + } + self.limits.enabling = true; + self.call( + Call::UpdateSettings, + json!({"patch": {"cursorKeychainUsageEnabled": true}}), + Pending::EnableCursor, + ); + cx.notify(); + } + + fn confirm_redeem(&mut self, target: Target, cx: &mut Context) { + self.limits.confirm = Some(target); + cx.notify(); + } + + fn redeem(&mut self, cx: &mut Context) { + let Some(target) = self.limits.confirm.take() else { + return; + }; + let key = (target.key.clone(), target.window.clone()); + self.limits.busy = Some(key.clone()); + self.limits.status.remove(&key); + self.call( + Call::ConsumeResetCredit, + json!({"instanceId": target.instance}), + Pending::Redeem { key }, + ); + cx.notify(); + } + + /// `needsCursorKeychainAccess` unless a Cursor account is already read (`usage.ts:18-44`). + fn cursor_prompt_needed(&self) -> bool { + let Some(shown) = &self.shown else { + return false; + }; + let sources = &shown.summary.sources; + let has_account = sources + .iter() + .any(|source| source.provider == Provider::Cursor && source.host_id == "cursor.com"); + !has_account && self.limits.cursor_ready && sources.iter().any(|source| source.has_action) + } + + /// The overview's `showChatGptSharing`. + pub(super) fn chatgpt_sharing(&self) -> bool { + self.limits.natives.iter().any(Native::uses_chatgpt_sharing) + } + + pub(super) fn cursor_enable_row(&self, cx: &mut Context) -> Option { + if !self.cursor_prompt_needed() { + return None; + } + let theme = cx.theme(); + let label = self.limits.environment.clone(); + let enabling = self.limits.enabling; + Some( + h_flex() + .min_w_0() + .items_baseline() + .justify_between() + .gap_4() + .text_sm() + .child( + h_flex() + .min_w_0() + .items_center() + .gap_2() + .child( + div() + .size(px(8.)) + .flex_shrink_0() + .rounded_full() + .bg(tint(Provider::Cursor, cx)), + ) + .child(mark(Provider::Cursor, 16., cx)) + .child(div().truncate().child("Cursor")), + ) + .child( + inline_button("usage-cursor-enable-row", "Enable", enabling, theme.primary) + .tooltip(tooltip(CURSOR_KEYCHAIN_COPY)) + .when(!enabling, |button| { + button.on_click(cx.listener(|this, _, _, cx| this.enable_cursor(cx))) + }) + .id(("usage-cursor-enable-row", label.len())), + ) + .into_any_element(), + ) + } + + pub(super) fn chatgpt_row(&self, cx: &App) -> AnyElement { + let theme = cx.theme(); + h_flex() + .items_center() + .justify_between() + .gap_2() + .text_xs() + .text_color(theme.muted_foreground) + .child("ChatGPT shared usage") + .child(manage_usage_button( + "usage-chatgpt-manage", + CHATGPT_USAGE_URL, + cx, + )) + .into_any_element() + } + + pub(super) fn limits_body(&self, vw: f32, cx: &mut Context) -> AnyElement { + let st = &self.limits; + let now = st.now; + let accounts = limits::collect_accounts(&st.natives); + let pools = limits::collect_pools(&accounts, now); + let notices = limits::collect_notices(&st.natives); + let links = limits::collect_external_links(&st.natives, &st.environment); + let prompt = self.cursor_prompt_needed(); + let theme = cx.theme(); + let muted = theme.muted_foreground; + let position = |driver: &str| { + pools + .iter() + .position(|pool| pool.driver == driver) + .map_or(-1, |index| index as isize) + }; + let prompt_at = (position("codex").max(position("claudeAgent")) + 1) as usize; + + let side = if vw >= 640. { 24. } else { 20. }; + let modal = if vw >= 640. { + let pad = if vw >= 1024. { 40. } else { 24. }; + (vw - 2. * pad).min(1280.) + } else { + vw + }; + let content = (modal.min(CONTENT_MAX + 2. * side) - 2. * side).max(0.); + let two_columns = vw >= 768.; + let bar_width = if two_columns { + content - 176. - 24. - 34. + } else { + content - 34. + }; + + let mut page = v_flex().gap_8(); + if pools.is_empty() && notices.is_empty() && !prompt && links.is_empty() { + page = + page.child(div().text_sm().text_color(muted).child( + "No provider on the selected environments reports subscription limits.", + )); + } + let weak = cx.weak_entity(); + for (index, pool) in pools.iter().enumerate() { + if prompt && index == prompt_at { + page = page.child(self.cursor_section(cx)); + } + page = page.child(self.pool_section(index, pool, &weak, bar_width, two_columns, cx)); + } + if prompt && prompt_at == pools.len() { + page = page.child(self.cursor_section(cx)); + } + for (index, link) in links.iter().enumerate() { + page = page.child(link_card(index, link, cx)); + } + if !notices.is_empty() { + page = page.child(notice_alert(¬ices, cx)); + } + page.into_any_element() + } + + fn cursor_section(&self, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let enabling = self.limits.enabling; + let label = self.limits.environment.clone(); + v_flex() + .gap_3() + .child( + h_flex() + .items_center() + .gap_2() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(provider_mark_at( + &Mark { + driver: "cursor".to_owned(), + display_name: "Cursor".to_owned(), + badge: None, + }, + 20., + 16., + 0.8, + cx, + )) + .child("Cursor"), + ) + .child( + v_flex() + .items_start() + .gap_3() + .rounded(px(8.)) + .border_1() + .border_color(theme.border.opacity(0.6)) + .p_4() + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(CURSOR_KEYCHAIN_COPY), + ) + .child( + div() + .id("usage-cursor-enable") + .px(px(10.)) + .h(px(28.)) + .flex() + .items_center() + .rounded(px(6.)) + .border_1() + .border_color(theme.border) + .text_sm() + .font_weight(FontWeight::MEDIUM) + .when(enabling, |button| button.opacity(0.64)) + .when(!enabling, |button| { + button + .cursor_pointer() + .hover(|style| style.bg(theme.foreground.opacity(0.06))) + .on_click(cx.listener(|this, _, _, cx| this.enable_cursor(cx))) + }) + .tooltip(tooltip(format!("Enable Cursor usage from {label}"))) + .child("Enable"), + ), + ) + .into_any_element() + } + + fn pool_section( + &self, + pool_index: usize, + pool: &Pool, + weak: &WeakEntity, + bar_width: f32, + two_columns: bool, + cx: &mut Context, + ) -> AnyElement { + let color = pool_color(&pool.driver, cx); + let label = driver_label(&pool.driver); + let heading = h_flex() + .items_center() + .gap_2() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(provider_mark_at( + &Mark { + driver: pool.driver.clone(), + display_name: label.clone(), + badge: None, + }, + 20., + 16., + 0.8, + cx, + )) + .child(label); + let env = Env { + now: self.limits.now, + environment: self.limits.environment.clone(), + reveal: self.limits.reveal, + bar_width, + weak: weak.clone(), + }; + let cards = pool + .display_windows() + .into_iter() + .enumerate() + .map(|(window_index, window)| { + self.window_card( + pool_index, + window_index, + pool, + window, + color, + &env, + two_columns, + cx, + ) + }); + v_flex() + .gap_3() + .child(heading) + .children(cards) + .into_any_element() + } + + #[allow(clippy::too_many_arguments)] + fn window_card( + &self, + pool_index: usize, + window_index: usize, + pool: &Pool, + window: &PoolWindow, + color: Hsla, + env: &Env, + two_columns: bool, + cx: &mut Context, + ) -> AnyElement { + let theme = cx.theme(); + let (muted, foreground, border) = (theme.muted_foreground, theme.foreground, theme.border); + let cursor = if pool.driver == "cursor" { + cursor_window_details(&window.id) + } else { + None + }; + let label = cursor.map_or(window.label.as_str(), |(label, _)| label); + let description = cursor.map(|(_, description)| description); + let next_refill = window + .resets + .iter() + .find(|reset| reset.restores_percent > 0) + .map(|reset| reset.restores_percent); + let columns = window.columns.len(); + let pace = window.pace.map(|pace| { + let icon = match pace { + Pace::Ahead => Icons::TrendingUp, + Pace::On => Icons::Gauge, + Pace::Under => Icons::TrendingDown, + }; + div() + .id(("usage-pace", pool_index * 1000 + window_index)) + .flex() + .text_color(muted) + .child(Icon::new(icon).size(px(14.))) + .tooltip(tooltip(pace.description())) + }); + let left = v_flex() + .gap_1() + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(label.to_owned()), + ) + .child( + h_flex() + .items_baseline() + .gap_1() + .child( + div() + .text_size(px(30.)) + .line_height(px(36.)) + .font_weight(FontWeight::SEMIBOLD) + .child(format!("{}%", window.remaining_percent)), + ) + .child(div().text_sm().text_color(muted).child("left")) + .children(pace), + ) + .children(next_refill.filter(|_| columns > 1).map(|percent| { + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .child(format!("\u{21bb} +{percent}%")) + })); + let bar = self.pool_bar(pool_index, window_index, pool, window, color, env, cx); + v_flex() + .gap_x(px(24.)) + .gap_y_3() + .rounded(px(8.)) + .border_1() + .border_color(border.opacity(0.6)) + .p_4() + .child( + div() + .flex() + .when(two_columns, |row| row.flex_row().gap(px(24.))) + .when(!two_columns, |row| row.flex_col().gap_3()) + .child( + div() + .when(two_columns, |column| column.w(px(176.)).flex_shrink_0()) + .child(left), + ) + .child(div().flex_1().min_w_0().child(bar)), + ) + .children(description.map(|text| div().text_xs().text_color(muted).child(text))) + .children(self.redeem_statuses(pool, window, foreground)) + .into_any_element() + } + + fn redeem_statuses( + &self, + pool: &Pool, + window: &PoolWindow, + foreground: Hsla, + ) -> Vec { + let mut rows = Vec::new(); + for member in &window.members { + let account = &pool.accounts[member.account]; + let key = (account.key.clone(), member.window.id.clone()); + let busy = self.limits.busy.as_ref() == Some(&key); + let text = if busy { + Some("Using reset credit\u{2026}".to_owned()) + } else { + self.limits.status.get(&key).cloned() + }; + if let Some(text) = text { + rows.push( + div() + .text_xs() + .child( + div() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child(format!("{} ", account_name_text(account, &pool.driver))), + ) + .child(text) + .into_any_element(), + ); + } + } + rows + } + + #[allow(clippy::too_many_arguments)] + fn pool_bar( + &self, + pool_index: usize, + window_index: usize, + pool: &Pool, + window: &PoolWindow, + color: Hsla, + env: &Env, + cx: &mut Context, + ) -> AnyElement { + let columns = window.columns.len(); + let wide = env.bar_width >= WIDE_BAR; + let show_name = columns > 1; + let mut present = 0usize; + let mut cells: Vec = Vec::new(); + let mut legend: Vec = Vec::new(); + for (column, slot) in window.columns.iter().enumerate() { + let Some(member_index) = *slot else { + cells.push(div().flex_1().min_w_0().into_any_element()); + continue; + }; + present += 1; + let part = Segment::new( + pool_index * 1_000_000 + window_index * 1_000 + column, + present, + pool, + window, + member_index, + color, + self, + env, + cx, + ); + cells.push( + div() + .flex_1() + .min_w_0() + .child(part.bar(wide, show_name, env.bar_width / columns as f32, cx)) + .into_any_element(), + ); + if !wide { + legend.push(part.legend_row(show_name, cx)); + } + } + v_flex() + .gap_1() + .child(h_flex().gap_1().children(cells)) + .children((!legend.is_empty()).then(|| v_flex().gap_0p5().children(legend))) + .into_any_element() + } + + /// The confirm dialog, over the page. + pub(super) fn reset_credit_dialog(&self, cx: &mut Context) -> Option { + self.limits.confirm.as_ref()?; + let theme = cx.theme(); + let (border, background, muted) = (theme.border, theme.background, theme.muted_foreground); + let backdrop = background.opacity(if theme.is_dark() { 0.64 } else { 0.6 }); + let cancel = div() + .id("usage-credit-cancel") + .h(px(32.)) + .px(px(12.)) + .flex() + .items_center() + .rounded(px(6.)) + .border_1() + .border_color(border) + .text_sm() + .font_weight(FontWeight::MEDIUM) + .cursor_pointer() + .hover(|style| style.bg(theme.foreground.opacity(0.06))) + .on_click(cx.listener(|this, _, _, cx| { + this.limits.confirm = None; + cx.notify(); + })) + .child("Cancel"); + let confirm = div() + .id("usage-credit-confirm") + .h(px(32.)) + .px(px(12.)) + .flex() + .items_center() + .rounded(px(6.)) + .bg(theme.primary) + .text_color(theme.primary_foreground) + .text_sm() + .font_weight(FontWeight::MEDIUM) + .cursor_pointer() + .on_click(cx.listener(|this, _, _, cx| this.redeem(cx))) + .child("Use credit"); + Some( + div() + .absolute() + .inset_0() + .child( + div() + .id("usage-credit-backdrop") + .absolute() + .inset_0() + .occlude() + .bg(backdrop) + .on_click(cx.listener(|this, _, _, cx| { + this.limits.confirm = None; + cx.notify(); + })), + ) + .child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child( + v_flex() + .occlude() + .w(px(448.)) + .max_w_full() + .gap_4() + .p_6() + .rounded(px(16.)) + .border_1() + .border_color(border) + .bg(background) + .shadow_lg() + .child( + v_flex() + .gap_2() + .child( + div() + .text_lg() + .font_weight(FontWeight::SEMIBOLD) + .child("Use a reset credit?"), + ) + .child(div().text_sm().text_color(muted).child( + "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.", + )), + ) + .child( + h_flex() + .justify_end() + .gap_2() + .child(cancel) + .child(confirm), + ), + ), + ) + .into_any_element(), + ) + } +} + +/// What a segment and its popover need from the page. +struct Env { + now: i64, + environment: String, + reveal: bool, + bar_width: f32, + weak: WeakEntity, +} + +fn pool_color(driver: &str, cx: &App) -> Hsla { + Provider::ORDER + .into_iter() + .find(|provider| provider.driver_kind() == driver) + .map_or(cx.theme().foreground, |provider| tint(provider, cx)) +} + +fn account_name_text(account: &Account, driver: &str) -> String { + if let Some(name) = &account.display_name { + return name.clone(); + } + if let Some(email) = &account.email { + return limits::account_initials(email); + } + driver_label(driver) +} + +/// `oklch(l c h)` as an sRGB color. +fn oklch(lightness: f32, chroma: f32, hue: f32) -> Hsla { + let (a, b) = ( + chroma * hue.to_radians().cos(), + chroma * hue.to_radians().sin(), + ); + let l = (lightness + 0.396_337_78 * a + 0.215_803_76 * b).powi(3); + let m = (lightness - 0.105_561_346 * a - 0.063_854_17 * b).powi(3); + let s = (lightness - 0.089_484_18 * a - 1.291_485_5 * b).powi(3); + let linear = [ + 4.076_741_7 * l - 3.307_711_6 * m + 0.230_969_94 * s, + -1.268_438 * l + 2.609_757_4 * m - 0.341_319_4 * s, + -0.004_196_086_3 * l - 0.703_418_6 * m + 1.707_614_7 * s, + ]; + let encode = |value: f32| { + let value = value.clamp(0., 1.); + let encoded = if value <= 0.003_130_8 { + 12.92 * value + } else { + 1.055 * value.powf(1. / 2.4) - 0.055 + }; + (encoded * 255.).round() as u32 + }; + rgb((encode(linear[0]) << 16) | (encode(linear[1]) << 8) | encode(linear[2])).into() +} + +/// `AccountChip`. +fn account_chip(email: &str) -> gpui::Div { + let hue = limits::account_hue(email) as f32; + div() + .size(px(16.)) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .rounded_full() + .bg(oklch(0.85, 0.08, hue)) + .text_color(oklch(0.35, 0.1, hue)) + .text_size(px(10.)) + .line_height(px(12.)) + .font_weight(FontWeight::SEMIBOLD) + .child(limits::account_initials(email)) +} + +/// `AccountName`. +fn account_name(account: &Account, driver: &str) -> AnyElement { + if let Some(name) = &account.display_name { + return div().truncate().child(name.clone()).into_any_element(); + } + match &account.email { + Some(email) => account_chip(email).into_any_element(), + None => div() + .truncate() + .child(driver_label(driver)) + .into_any_element(), + } +} + +/// `AccountAvatar`. +fn account_avatar(account: &Account, driver: &str, cx: &App) -> AnyElement { + if account.redeem.is_some() { + let display_name = account + .display_name + .clone() + .unwrap_or_else(|| driver_label(driver)); + let accent = account.accent_color.as_deref().and_then(normalize_accent); + let badge = account + .display_name + .as_ref() + .map(|name| accent_badge(name, accent)); + return provider_mark_at( + &Mark { + driver: driver.to_owned(), + display_name, + badge, + }, + 20., + 16., + 1., + cx, + ) + .into_any_element(); + } + match &account.email { + Some(email) => account_chip(email).into_any_element(), + None => div().into_any_element(), + } +} + +/// One member's segment: the bar, its legend row and the popover both open. +struct Segment { + id: usize, + position: usize, + account: Account, + driver: String, + window: LimitWindow, + remaining: i64, + time_left: Option, + resets_in: Option, + credits: i64, + restores: i64, + refill: f64, + has_reset: bool, + color: Hsla, + now: i64, + environment: String, + reveal: bool, + key: (String, String), + busy: bool, + weak: WeakEntity, +} + +impl Segment { + #[allow(clippy::too_many_arguments)] + fn new( + id: usize, + position: usize, + pool: &Pool, + window: &PoolWindow, + member_index: usize, + pool_color: Hsla, + dialog: &UsageDialog, + env: &Env, + _cx: &App, + ) -> Self { + let member = &window.members[member_index]; + let account = pool.accounts[member.account].clone(); + let reset = window.reset_of(member_index); + let color = account + .accent_color + .as_deref() + .and_then(normalize_accent) + .map_or(pool_color, |accent| rgb(accent).into()); + let credits = account + .redeem + .as_ref() + .and(account.limits.reset_credits.as_ref()) + .map_or(0, |credits| credits.available); + let key = (account.key.clone(), member.window.id.clone()); + Self { + id, + position, + driver: pool.driver.clone(), + remaining: member.window.remaining_percent(), + time_left: member.window.pace_remaining_percent(env.now), + resets_in: member.window.resets_in(env.now), + credits, + restores: reset.map_or(0, |reset| reset.restores_percent), + refill: member.refill_percent, + has_reset: reset.is_some(), + window: member.window.clone(), + color, + now: env.now, + environment: env.environment.clone(), + reveal: env.reveal, + busy: dialog.limits.busy.as_ref() == Some(&key), + key, + weak: env.weak.clone(), + account, + } + } + + fn label(&self) -> String { + let who = self.account.display_name.clone().unwrap_or_else(|| { + self.account + .email + .as_deref() + .map_or_else(|| driver_label(&self.driver), limits::account_initials) + }); + let mut label = format!("{who}: {}% left", self.remaining); + if let Some(resets_in) = &self.resets_in { + label.push_str(&format!(", {resets_in}")); + } + if let Some(left) = self.time_left { + label.push_str(&format!(", {left}% of the window left")); + } + if self.credits > 0 { + let noun = if self.credits == 1 { + "credit" + } else { + "credits" + }; + label.push_str(&format!(", {} reset {noun} banked", self.credits)); + } + label + } + + fn chip_text(&self) -> Option { + self.resets_in + .as_ref() + .map(|text| text.replace("resets in ", "\u{21bb} ")) + } + + fn bar(&self, wide: bool, show_name: bool, width: f32, cx: &App) -> AnyElement { + let theme = cx.theme(); + let (foreground, muted, background) = + (theme.foreground, theme.muted_foreground, theme.background); + let height = if wide { 32. } else { 20. }; + let fraction = (self.remaining as f32 / 100.).clamp(0., 1.); + let mut track = div() + .id(("usage-limit-segment", self.id)) + .relative() + .w_full() + .h(px(height)) + .rounded(px(6.)) + .overflow_hidden() + .bg(theme.muted) + .cursor_default(); + if fraction > 0. { + track = track.child( + div() + .absolute() + .top_0() + .bottom_0() + .left_0() + .w(relative(fraction)) + .rounded(px(6.)) + .bg(self.color.opacity(0.35)), + ); + } + let hatch = (self.remaining < 100 && self.has_reset && self.refill > 0.) + .then(|| ((100 - self.remaining) as f64).min(self.refill) as f32 / 100.); + if let Some(share) = hatch { + track = track.child( + div() + .absolute() + .top_0() + .bottom_0() + .right_0() + .w(relative(share)) + .bg(pattern_slash(self.color.opacity(0.2), 1., 5.)), + ); + } + if let Some(left) = self.time_left.filter(|_| width >= 6.) { + let at = (left as f32 / 100.).clamp(0., 1.); + track = track + .child( + div() + .absolute() + .top_0() + .bottom_0() + .left(relative(at)) + .w_0() + .child( + div() + .absolute() + .top_0() + .bottom_0() + .left(px(-3.)) + .w(px(6.)) + .bg(background), + ), + ) + .child( + div() + .absolute() + .top(px(2.)) + .bottom(px(2.)) + .left(relative(at)) + .w_0() + .child( + div() + .absolute() + .top_0() + .bottom_0() + .left(px(-1.)) + .w(px(2.)) + .rounded_full() + .bg(foreground), + ), + ); + } + if wide { + let chip = self.chip_text().map(|text| { + h_flex() + .flex_shrink_0() + .items_center() + .gap_1() + .rounded(px(4.)) + .bg(background.opacity(0.85)) + .px(px(6.)) + .py(px(2.)) + .text_size(px(11.)) + .child(text) + .when(self.credits > 0, |chip| { + chip.child(div().text_color(muted).child("\u{b7}")) + .child(Icon::new(Icons::Ticket).size(px(12.))) + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .child(self.credits.to_string()), + ) + }) + }); + track = track.child( + h_flex() + .absolute() + .inset_0() + .items_center() + .justify_between() + .gap_2() + .px_2() + .text_xs() + .child( + h_flex() + .min_w_0() + .items_center() + .gap_1() + .when(show_name, |row| { + row.child( + div() + .min_w_0() + .font_weight(FontWeight::MEDIUM) + .child(account_name(&self.account, &self.driver)), + ) + .child( + div() + .flex_shrink_0() + .font_weight(FontWeight::SEMIBOLD) + .child(format!("{}%", self.remaining)), + ) + }), + ) + .children(chip), + ); + } else { + track = track.child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .text_size(px(10.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(foreground.opacity(0.8)) + .child(self.position.to_string()), + ); + } + let popover = self.popover(); + HoverCard::new(("usage-limit-popover", self.id)) + .anchor(Anchor::BottomCenter) + .open_delay(Duration::from_millis(150)) + .trigger_style(StyleRefinement::default().w_full()) + .trigger(track.tooltip(tooltip(self.label()))) + .content(move |_, _, cx| popover.render(cx)) + .into_any_element() + } + + fn legend_row(&self, show_name: bool, cx: &App) -> AnyElement { + let theme = cx.theme(); + let (muted, foreground) = (theme.muted_foreground, theme.foreground); + let right = self.chip_text().map(|text| { + h_flex() + .flex_shrink_0() + .items_center() + .gap_1() + .text_size(px(11.)) + .text_color(muted) + .child(text) + .when(self.credits > 0, |row| { + row.child("\u{b7}") + .child(Icon::new(Icons::Ticket).size(px(12.))) + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .text_color(foreground) + .child(self.credits.to_string()), + ) + }) + }); + let row = h_flex() + .id(("usage-limit-legend", self.id)) + .w_full() + .h(px(24.)) + .items_center() + .justify_between() + .gap_2() + .px_1() + .rounded(px(6.)) + .text_xs() + .hover(|style| style.bg(foreground.opacity(0.06))) + .child( + h_flex() + .min_w_0() + .items_center() + .gap_2() + .child( + div() + .size(px(16.)) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .rounded(px(4.)) + .bg(self.color.opacity(0.35)) + .text_size(px(10.)) + .font_weight(FontWeight::SEMIBOLD) + .child(self.position.to_string()), + ) + .when(show_name, |row| { + row.child( + div() + .min_w_0() + .font_weight(FontWeight::MEDIUM) + .child(account_name(&self.account, &self.driver)), + ) + }) + .child( + div() + .font_weight(FontWeight::SEMIBOLD) + .child(format!("{}%", self.remaining)), + ), + ) + .children(right); + let popover = self.popover(); + HoverCard::new(("usage-limit-legend-popover", self.id)) + .anchor(Anchor::BottomCenter) + .open_delay(Duration::from_millis(150)) + .trigger_style(StyleRefinement::default().w_full()) + .trigger(row) + .content(move |_, _, cx| popover.render(cx)) + .into_any_element() + } + + fn popover(&self) -> Rc { + Rc::new(Popover { + id: self.id, + account: self.account.clone(), + driver: self.driver.clone(), + window: self.window.clone(), + remaining: self.remaining, + time_left: self.time_left, + resets_in: self.resets_in.clone(), + restores: self.restores, + now: self.now, + environment: self.environment.clone(), + reveal: self.reveal, + key: self.key.clone(), + busy: self.busy, + weak: self.weak.clone(), + }) + } +} + +/// `SegmentPopoverContent`. +struct Popover { + id: usize, + account: Account, + driver: String, + window: LimitWindow, + remaining: i64, + time_left: Option, + resets_in: Option, + restores: i64, + now: i64, + environment: String, + reveal: bool, + key: (String, String), + busy: bool, + weak: WeakEntity, +} + +impl Popover { + fn row(label: &'static str, value: String, cx: &App) -> gpui::Div { + h_flex() + .gap_x_3() + .items_baseline() + .child( + div() + .w(px(72.)) + .flex_shrink_0() + .text_color(cx.theme().muted_foreground) + .child(label), + ) + .child(div().min_w_0().child(value)) + } + + fn render(&self, cx: &App) -> AnyElement { + let theme = cx.theme(); + let (border, muted, foreground) = ( + theme.border.opacity(0.6), + theme.muted_foreground, + theme.foreground, + ); + let account = &self.account; + let title = account + .display_name + .clone() + .unwrap_or_else(|| driver_label(&self.driver)); + let email = account + .email + .as_deref() + .map(str::trim) + .filter(|email| !email.is_empty()); + let email_line = email.map(|email| { + let shown = if self.reveal { + email.to_owned() + } else { + redacted_placeholder(email) + }; + let weak = self.weak.clone(); + div() + .id(("usage-limit-email", self.id)) + .w_auto() + .cursor_pointer() + .rounded(px(4.)) + .font_family("monospace") + .text_size(px(11.)) + .text_color(muted) + .hover(move |style| style.text_color(foreground)) + .when(!self.reveal, |text| text.opacity(0.6)) + .tooltip(tooltip(if self.reveal { + "Click to hide email" + } else { + "Click to reveal email" + })) + .on_click(move |_, _, cx| { + weak.update(cx, |this, cx| { + this.limits.reveal = !this.limits.reveal; + cx.notify(); + }) + .ok(); + }) + .child(shown) + }); + let header = v_flex() + .min_w_0() + .gap_0p5() + .child( + h_flex() + .items_center() + .gap_2() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(account_avatar(account, &self.driver, cx)) + .child(div().truncate().child(title)), + ) + .children(email_line); + let mut identity = v_flex() + .gap_1() + .border_t_1() + .border_color(border) + .pt(px(10.)); + let mut rows = 0; + if let Some(plan) = account.plan.as_ref().filter(|plan| !plan.is_empty()) { + identity = identity.child(Self::row("Plan", plan.clone(), cx)); + rows += 1; + } + if !self.environment.is_empty() { + identity = identity.child(Self::row("Signed in", self.environment.clone(), cx)); + rows += 1; + } + let mut figures = v_flex() + .gap_1() + .border_t_1() + .border_color(border) + .pt(px(10.)) + .child(Self::row("Left", format!("{}%", self.remaining), cx)); + if let Some(at) = self.window.resets_at { + let zone = crate::usage_data::Zone::detect(); + let mut text = crate::usage_data::format_upcoming(at, self.now, &zone); + if let Some(resets_in) = &self.resets_in { + text.push_str(&format!( + " \u{b7} {}", + resets_in.replace("resets in ", "in ") + )); + } + figures = figures.child(Self::row("Resets", text, cx)); + } + if let Some(left) = self.time_left { + figures = figures.child(Self::row("Even pace", format!("{left}% left by now"), cx)); + } + if self.restores > 0 { + figures = figures.child(Self::row( + "Restores", + format!("+{}% of pool", self.restores), + cx, + )); + } + let credits = account + .redeem + .as_ref() + .zip(account.limits.reset_credits.as_ref()) + .filter(|(_, credits)| credits.available > 0); + let credit_section = credits.map(|(instance, credits)| { + let weak = self.weak.clone(); + let target = Target { + key: self.key.0.clone(), + window: self.key.1.clone(), + instance: instance.clone(), + name: account_name_text(account, &self.driver), + }; + let busy = self.busy; + h_flex() + .items_center() + .gap_3() + .border_t_1() + .border_color(border) + .pt(px(10.)) + .text_color(muted) + .child(limits::reset_credits_summary(credits, self.now, true)) + .child( + div() + .id(("usage-limit-use-reset", self.id)) + .ml_auto() + .h(px(24.)) + .px(px(8.)) + .flex() + .items_center() + .rounded(px(6.)) + .border_1() + .border_color(theme.border) + .text_color(foreground) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .when(busy, |button| button.opacity(0.64)) + .when(!busy, |button| { + button + .cursor_pointer() + .hover(move |style| style.bg(foreground.opacity(0.06))) + .on_click(move |_, _, cx| { + let target = target.clone(); + weak.update(cx, |this, cx| this.confirm_redeem(target, cx)) + .ok(); + }) + }) + .child(if busy { "Using\u{2026}" } else { "Use reset" }), + ) + }); + let _ = rows; + v_flex() + .w(px(288.)) + .gap(px(10.)) + .text_xs() + .child(header) + .children((rows > 0).then_some(identity)) + .child(figures) + .children(credit_section) + .into_any_element() + } +} + +/// `redactedPlaceholder`: a stable stand-in of the same shape. +fn redacted_placeholder(value: &str) -> String { + const ALPHABET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789"; + let mut state: u32 = 0x811c_9dc5; + for unit in value.encode_utf16() { + state ^= u32::from(unit); + state = state.wrapping_mul(0x0100_0193); + } + let mut next = || { + state = (state ^ (state >> 13)).wrapping_mul(0x85eb_ca6b); + state = (state ^ (state >> 16)).wrapping_mul(0xc2b2_ae35); + let magnitude = (state as i32).unsigned_abs() as usize; + char::from(ALPHABET[magnitude % ALPHABET.len()]) + }; + value + .chars() + .map(|ch| { + if matches!(ch, '@' | '.' | '-' | '_') { + ch + } else { + next() + } + }) + .collect() +} + +fn inline_button( + id: &'static str, + text: &'static str, + disabled: bool, + color: Hsla, +) -> gpui::Stateful { + div() + .id(id) + .text_color(color) + .when(disabled, |button| button.opacity(0.64)) + .when(!disabled, |button| button.cursor_pointer()) + .child(text) +} + +/// `Button` `ghost-muted` `xs` with the external-link icon. +fn manage_usage_button(id: &'static str, url: &'static str, cx: &App) -> gpui::Stateful { + let theme = cx.theme(); + let (muted, foreground) = (theme.muted_foreground, theme.foreground); + let url = url.to_owned(); + h_flex() + .id(id) + .flex_shrink_0() + .h(px(24.)) + .px(px(8.)) + .gap_1() + .items_center() + .rounded(px(6.)) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(muted) + .cursor_pointer() + .hover(move |style| style.bg(foreground.opacity(0.06)).text_color(foreground)) + .on_click(move |_, _, cx| cx.open_url(&url)) + .child("Manage usage") + .child(Icon::new(Icons::ExternalLink).size(px(14.))) +} + +fn link_card(index: usize, link: &Link, cx: &App) -> AnyElement { + let theme = cx.theme(); + let muted = theme.muted_foreground; + let chatgpt = link.url == CHATGPT_USAGE_URL; + let url = link.url.clone(); + let open = h_flex() + .id(("usage-link-open", index)) + .flex_shrink_0() + .h(px(24.)) + .px(px(8.)) + .gap_1() + .items_center() + .rounded(px(6.)) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(muted) + .cursor_pointer() + .hover({ + let foreground = theme.foreground; + move |style| style.bg(foreground.opacity(0.06)).text_color(foreground) + }) + .on_click(move |_, _, cx| cx.open_url(&url)) + .child("Manage usage") + .child(Icon::new(Icons::ExternalLink).size(px(14.))); + h_flex() + .flex_wrap() + .items_center() + .justify_between() + .gap_3() + .rounded(px(12.)) + .border_1() + .border_color(theme.border) + .p_4() + .child( + h_flex() + .min_w_0() + .flex_1() + .items_center() + .gap_3() + .when(chatgpt, |row| { + row.child(provider_mark_at( + &Mark { + driver: "codex".to_owned(), + display_name: "OpenAI".to_owned(), + badge: None, + }, + 20., + 20., + 1., + cx, + )) + }) + .child( + v_flex() + .min_w_0() + .gap_1() + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(link.label.clone()), + ) + .child( + div() + .text_xs() + .text_color(muted) + .child(link.accounts.join(", ")), + ) + .when(chatgpt, |column| { + column.child( + div() + .text_xs() + .text_color(muted) + .child("View usage in ChatGPT with your connected account."), + ) + }) + .when(!chatgpt, |column| { + column.children(link.message.as_ref().map(|message| { + div() + .max_w(px(576.)) + .text_xs() + .text_color(muted) + .child(message.clone()) + })) + }), + ), + ) + .child(open) + .into_any_element() +} + +/// `LimitNotices`: a warning alert with one title per notice. +fn notice_alert(notices: &[String], cx: &App) -> AnyElement { + let theme = cx.theme(); + h_flex() + .items_start() + .gap_3() + .rounded(px(8.)) + .border_1() + .border_color(theme.warning.opacity(0.32)) + .bg(theme.warning.opacity(0.04)) + .px_4() + .py_3() + .child( + Icon::new(Icons::TriangleAlert) + .size(px(16.)) + .text_color(theme.warning), + ) + .child( + v_flex() + .min_w_0() + .gap_1() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .children(notices.iter().map(|notice| div().child(notice.clone()))), + ) + .into_any_element() +} diff --git a/apps/desktop-gpui/src/usage.rs b/apps/desktop-gpui/src/usage.rs index f8a48b39eacf..4e4ae78718f1 100644 --- a/apps/desktop-gpui/src/usage.rs +++ b/apps/desktop-gpui/src/usage.rs @@ -8,9 +8,41 @@ const MINUTE: i64 = 60_000; const HOUR: i64 = 60 * MINUTE; const DAY: i64 = 24 * HOUR; +/// `ServerProviderUsageWindow.kind`; the declaration order is `WINDOW_KIND_ORDER`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Kind { + Session, + Weekly, + Monthly, + Other, +} + +impl Kind { + fn decode(text: &str) -> Option { + Some(match text { + "session" => Self::Session, + "weekly" => Self::Weekly, + "monthly" => Self::Monthly, + "other" => Self::Other, + _ => return None, + }) + } + + pub fn wire(self) -> &'static str { + match self { + Self::Session => "session", + Self::Weekly => "weekly", + Self::Monthly => "monthly", + Self::Other => "other", + } + } +} + /// `ServerProviderUsageWindow`. #[derive(Clone, Debug, PartialEq)] pub struct Window { + pub id: String, + pub kind: Kind, pub label: String, pub used_percent: f64, pub resets_at: Option, @@ -23,11 +55,30 @@ pub enum Reason { ProbeFailed, } +/// `ServerProviderResetCredits`. +#[derive(Clone, Debug, PartialEq)] +pub struct ResetCredits { + pub available: i64, + pub next_expires_at: Option, +} + +/// `ServerProviderUsageLimits.externalUsage`. +#[derive(Clone, Debug, PartialEq)] +pub struct External { + pub label: String, + pub url: String, +} + /// `ServerProviderUsageLimits`. #[derive(Clone, Debug, PartialEq)] pub struct Limits { pub windows: Vec, pub unavailable: Option<(Reason, Option)>, + /// `Date.parse(checkedAt)`: `None` stands for `NaN`, which compares false with everything. + pub checked_at: Option, + pub fingerprint: Option, + pub reset_credits: Option, + pub external: Option, } impl Limits { @@ -42,6 +93,8 @@ impl Limits { .flatten() .filter_map(|window| { Some(Window { + id: window["id"].as_str()?.to_owned(), + kind: Kind::decode(window["kind"].as_str()?)?, label: window["label"].as_str()?.to_owned(), used_percent: window["usedPercent"].as_f64()?, resets_at: window["resetsAt"].as_str().and_then(parse_timestamp), @@ -61,9 +114,28 @@ impl Limits { .map(str::to_owned); Some((reason, message)) }); + let reset_credits = value["resetCredits"].as_object().and_then(|entry| { + Some(ResetCredits { + available: entry.get("availableCount")?.as_i64()?, + next_expires_at: entry + .get("nextExpiresAt") + .and_then(Value::as_str) + .and_then(parse_timestamp), + }) + }); + let external = value["externalUsage"].as_object().and_then(|entry| { + Some(External { + label: entry.get("label")?.as_str()?.to_owned(), + url: entry.get("url")?.as_str()?.to_owned(), + }) + }); Some(Self { windows, unavailable, + checked_at: value["checkedAt"].as_str().and_then(parse_timestamp), + fingerprint: value["credentialFingerprint"].as_str().map(str::to_owned), + reset_credits, + external, }) } @@ -92,21 +164,35 @@ impl Window { (100. - self.used_percent.clamp(0., 100.)).round() as i64 } - /// `paceOf`: `None` without a reset time and window length. - pub fn pace(&self, now: i64) -> Option { + /// `elapsedShare`: `None` without a reset time and window length. + pub fn elapsed_share(&self, now: i64) -> Option { let resets_at = self.resets_at?; - let length = (self.duration_mins? * MINUTE as f64) as i64; - if length <= 0 { + let length = self.duration_mins? * MINUTE as f64; + if length <= 0. { return None; } - let elapsed = ((length - (resets_at - now)) as f64 / length as f64).clamp(0., 1.); - let gap = self.used_percent - elapsed * 100.; - Some(if gap > 5. { - Pace::Ahead - } else if gap < -5. { - Pace::Under + Some(((length - (resets_at - now) as f64) / length).clamp(0., 1.)) + } + + /// `paceRemainingPercent`: the share of the window still to come, where even spending would sit. + pub fn pace_remaining_percent(&self, now: i64) -> Option { + Some(((1. - self.elapsed_share(now)?) * 100.).round() as i64) + } + + /// `paceOf`. + pub fn pace(&self, now: i64) -> Option { + Some(Pace::of_shares(self.used_percent, self.elapsed_share(now)?)) + } + + /// `formatResetsIn`. + pub fn resets_in(&self, now: i64) -> Option { + let at = self.resets_at?; + Some(if at <= now { + "resets now".to_owned() + } else if at - now < MINUTE { + "Resets momentarily".to_owned() } else { - Pace::On + format!("resets in {}", format_duration(at - now)) }) } @@ -135,6 +221,27 @@ pub enum Pace { } impl Pace { + /// `paceOfShares`. + pub fn of_shares(used_percent: f64, elapsed: f64) -> Self { + let gap = used_percent - elapsed * 100.; + if gap > 5. { + Self::Ahead + } else if gap < -5. { + Self::Under + } else { + Self::On + } + } + + /// `PACE[pace].label` of the usage page. + pub fn description(self) -> &'static str { + match self { + Self::Ahead => "Ahead of pace: spending faster than the window elapses", + Self::On => "On pace with the window", + Self::Under => "Under pace: headroom left for the rest of the window", + } + } + pub fn label(self) -> &'static str { match self { Self::Ahead => "Ahead of pace", @@ -217,6 +324,8 @@ mod tests { fn window(used: f64, resets: Option, mins: Option) -> Window { Window { + id: "five_hour".to_owned(), + kind: Kind::Session, label: "5h".to_owned(), used_percent: used, resets_at: resets, diff --git a/apps/desktop-gpui/src/usage_data.rs b/apps/desktop-gpui/src/usage_data.rs index 14b2a1f50a9b..d0a5aa7fb4e1 100644 --- a/apps/desktop-gpui/src/usage_data.rs +++ b/apps/desktop-gpui/src/usage_data.rs @@ -984,21 +984,54 @@ impl Zone { fn civil(&self, instant_ms: i64) -> Civil { let local = instant_ms + self.offset_ms(instant_ms); let days = local.div_euclid(DAY_MS); - let (_, month, day) = civil_from_days(days); + let (year, month, day) = civil_from_days(days); Civil { days, + year, month, day, hour: local.rem_euclid(DAY_MS) / HOUR_MS, + minute: local.rem_euclid(HOUR_MS) / 60_000, } } } struct Civil { days: i64, + year: i64, month: i64, day: i64, hour: i64, + minute: i64, +} + +/// `formatUpcomingTimestamp` for the `en-US` locale at its default 12-hour clock. +pub fn format_upcoming(at: i64, now: i64, zone: &Zone) -> String { + let (target, today) = (zone.civil(at), zone.civil(now)); + let twelve = if target.hour % 12 == 0 { + 12 + } else { + target.hour % 12 + }; + let time = format!( + "{twelve}:{:02}\u{202f}{}", + target.minute, + if target.hour < 12 { "AM" } else { "PM" } + ); + let ahead = target.days - today.days; + let numeric = || { + if target.year == today.year { + format!("{}/{}", target.month, target.day) + } else { + format!("{}/{}/{}", target.month, target.day, target.year) + } + }; + match ahead { + 0 => time, + 1 => format!("tomorrow at {time}"), + -1 => format!("yesterday at {time}"), + _ => format!("{} {time}", numeric()), + } } fn hour_label(hour: i64) -> String { diff --git a/apps/desktop-gpui/src/usage_limits.rs b/apps/desktop-gpui/src/usage_limits.rs new file mode 100644 index 000000000000..e70bb72c525d --- /dev/null +++ b/apps/desktop-gpui/src/usage_limits.rs @@ -0,0 +1,1023 @@ +//! `packages/shared/src/usageLimits.ts`: the accounts, pools and notices behind the usage page's +//! Limits tab. One desktop window talks to one environment, so the multi-environment branches +//! (environment-prefixed notices, the "Signed in" list) collapse to the single environment label, +//! and the usage limit sources, which only the config stream carries, are not read. + +use crate::usage::{Kind, Limits, Pace, Reason, ResetCredits, Window, format_duration}; +use std::collections::HashMap; + +pub const CHATGPT_USAGE_URL: &str = "https://chatgpt.com/#settings/Usage"; + +/// `CURSOR_USAGE_WINDOWS`: id, label and description. +pub const CURSOR_USAGE_WINDOWS: [(&str, &str, &str); 3] = [ + ( + "totalPercentUsed", + "Overall", + "Combined usage across both allowances, not a third quota.", + ), + ( + "autoPercentUsed", + "Cursor Models", + "Grok and Composer use this first. Auto can use either pool.", + ), + ( + "apiPercentUsed", + "Other Models", + "Claude, GPT, and Gemini use this pool. Grok and Composer fall back here.", + ), +]; + +pub fn cursor_window_details(id: &str) -> Option<(&'static str, &'static str)> { + CURSOR_USAGE_WINDOWS + .iter() + .find(|(window, ..)| *window == id) + .map(|(_, label, description)| (*label, *description)) +} + +fn cursor_rank(id: &str) -> usize { + CURSOR_USAGE_WINDOWS + .iter() + .position(|(window, ..)| *window == id) + .unwrap_or(CURSOR_USAGE_WINDOWS.len()) +} + +/// The slice of a `ServerProvider` the limits read. +#[derive(Clone, Debug, PartialEq)] +pub struct Native { + pub instance_id: String, + pub driver: String, + pub display_name: Option, + pub accent_color: Option, + pub status: String, + pub enabled: bool, + pub installed: bool, + pub available: bool, + pub authenticated: bool, + pub email: Option, + pub plan: Option, + pub workspace_id: Option, + pub sharing: bool, + pub limits: Option, +} + +impl Native { + /// `providersWithLimits`. + fn reports_limits(&self) -> bool { + self.enabled && self.installed && self.available && self.limits.is_some() + } + + /// `usesChatGptSharing`. + pub fn uses_chatgpt_sharing(&self) -> bool { + self.authenticated && self.sharing + } +} + +/// `limitsNotice`. +pub fn limits_notice(limits: &Limits) -> Option { + limits.notice() +} + +fn normalized_email(email: Option<&str>) -> Option { + email + .map(|email| email.trim().to_lowercase()) + .filter(|email| !email.is_empty()) +} + +/// `accountKey`. +fn account_key(native: &Native) -> Option { + if let Some(email) = normalized_email(native.email.as_deref()) { + return Some(format!("{}:{email}", native.driver)); + } + let fingerprint = native.limits.as_ref()?.fingerprint.as_ref()?; + Some(format!("{}:credential:{fingerprint}", native.driver)) +} + +/// `accountKeyResolver` over the enabled logins, even the unreadable workspaces. +fn key_resolver(natives: &[Native]) -> impl Fn(&Native) -> Option + '_ { + let mut workspaces: HashMap> = HashMap::new(); + for native in natives.iter().filter(|native| native.enabled) { + if let (Some(key), Some(workspace)) = (account_key(native), native.workspace_id.as_deref()) + { + let known = workspaces.entry(key).or_default(); + if !known.contains(&workspace) { + known.push(workspace); + } + } + } + move |native| { + let key = account_key(native)?; + if let Some(workspace) = &native.workspace_id { + return Some(format!("{key}:{workspace}")); + } + match workspaces.get(&key) { + None => Some(key), + Some(known) if known.len() == 1 => Some(format!("{key}:{}", known[0])), + Some(_) => None, + } + } +} + +/// `LimitAccount`, for the one environment. +#[derive(Clone, Debug, PartialEq)] +pub struct Account { + pub key: String, + pub driver: String, + pub display_name: Option, + pub email: Option, + pub plan: Option, + pub accent_color: Option, + /// The instance whose reset credit `provider.consumeResetCredit` spends. + pub redeem: Option, + pub limits: Limits, +} + +impl Account { + fn sort_name(&self) -> String { + self.display_name + .as_deref() + .or(self.email.as_deref()) + .unwrap_or(&self.key) + .to_lowercase() + } +} + +/// `Date.parse(a) > Date.parse(b)`, false when either is `NaN`. +fn newer(next: Option, previous: Option) -> bool { + matches!((next, previous), (Some(next), Some(previous)) if next > previous) +} + +/// `collectLimitAccounts`: one account per login, however many instances share it. +pub fn collect_accounts(natives: &[Native]) -> Vec { + struct Credit { + checked_at: Option, + credits: ResetCredits, + redeem: Option, + } + let resolve = key_resolver(natives); + let mut accounts: Vec<(String, Account)> = Vec::new(); + let mut credits: HashMap = HashMap::new(); + let mut plans: HashMap, String)> = HashMap::new(); + for native in natives.iter().filter(|native| native.reports_limits()) { + let Some(limits) = native.limits.clone() else { + continue; + }; + if limits_notice(&limits).is_some() { + continue; + } + let key = resolve(native).unwrap_or_else(|| format!("environment:{}", native.instance_id)); + let next = Account { + key: format!("environment:{}", native.instance_id), + driver: native.driver.clone(), + display_name: native + .display_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_owned), + email: native.email.clone(), + plan: native.plan.clone(), + accent_color: native.accent_color.clone(), + redeem: Some(native.instance_id.clone()), + limits, + }; + if let Some(next_credits) = &next.limits.reset_credits + && credits + .get(&key) + .is_none_or(|previous| newer(next.limits.checked_at, previous.checked_at)) + { + credits.insert( + key.clone(), + Credit { + checked_at: next.limits.checked_at, + credits: next_credits.clone(), + redeem: next.redeem.clone(), + }, + ); + } + if let Some(plan) = next.plan.as_ref().filter(|plan| !plan.is_empty()) + && plans + .get(&key) + .is_none_or(|(previous, _)| newer(next.limits.checked_at, *previous)) + { + plans.insert(key.clone(), (next.limits.checked_at, plan.clone())); + } + let Some(slot) = accounts.iter().position(|(seen, _)| *seen == key) else { + accounts.push((key, next)); + continue; + }; + let previous = &accounts[slot].1; + let fresher = newer(next.limits.checked_at, previous.limits.checked_at); + let winner = if fresher { &next } else { previous }; + let credit = credits.get(&key); + let merged = Account { + display_name: previous + .display_name + .clone() + .or_else(|| next.display_name.clone()), + plan: plans + .get(&key) + .map(|(_, plan)| plan.clone()) + .or_else(|| previous.plan.clone()) + .or_else(|| next.plan.clone()), + accent_color: previous + .accent_color + .clone() + .or_else(|| next.accent_color.clone()), + redeem: match credit { + Some(credit) => credit.redeem.clone(), + None => winner + .redeem + .clone() + .or_else(|| previous.redeem.clone()) + .or_else(|| next.redeem.clone()), + }, + limits: Limits { + reset_credits: credit.map(|credit| credit.credits.clone()), + ..winner.limits.clone() + }, + ..previous.clone() + }; + accounts[slot].1 = merged; + } + accounts.into_iter().map(|(_, account)| account).collect() +} + +/// `collectLimitNotices` for one environment. +pub fn collect_notices(natives: &[Native]) -> Vec { + natives + .iter() + .filter(|native| native.reports_limits()) + .filter_map(|native| { + let limits = native.limits.as_ref()?; + if matches!(limits.unavailable, Some((Reason::Unsupported, _))) { + return None; + } + let notice = limits_notice(limits)?; + let name = native + .display_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(&native.driver); + Some(format!("{name}: {notice}")) + }) + .collect() +} + +/// One `collectExternalUsageLinks` entry. +#[derive(Clone, Debug, PartialEq)] +pub struct Link { + pub label: String, + pub url: String, + pub message: Option, + pub accounts: Vec, +} + +pub fn collect_external_links(natives: &[Native], environment: &str) -> Vec { + let mut links: Vec = Vec::new(); + for native in natives.iter().filter(|native| native.reports_limits()) { + let Some(limits) = &native.limits else { + continue; + }; + let Some(external) = &limits.external else { + continue; + }; + if !native.authenticated { + continue; + } + let account = format!( + "{} on {environment}", + native + .display_name + .as_deref() + .unwrap_or(&native.instance_id) + ); + let message = limits + .unavailable + .as_ref() + .and_then(|(_, message)| message.clone()); + match links.iter_mut().find(|link| link.url == external.url) { + Some(link) => { + link.label = external.label.clone(); + link.message = message; + if !link.accounts.contains(&account) { + link.accounts.push(account); + } + } + None => links.push(Link { + label: external.label.clone(), + url: external.url.clone(), + message, + accounts: vec![account], + }), + } + } + links +} + +/// `LimitPoolMember`: an account's window in a pool, as an index into `Pool::accounts`. +#[derive(Clone, Debug, PartialEq)] +pub struct Member { + pub account: usize, + pub window: Window, + pub refill_percent: f64, +} + +/// One entry of `LimitPoolWindow.resets`: `member` indexes `PoolWindow::members`. +#[derive(Clone, Debug, PartialEq)] +pub struct Reset { + pub member: usize, + pub at: i64, + pub restores_percent: i64, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PoolWindow { + pub id: String, + pub kind: Kind, + pub label: String, + pub members: Vec, + /// One slot per account of the pool, holding the index of its member when it reports the window. + pub columns: Vec>, + pub remaining_percent: i64, + pub used_percent: i64, + pub pace: Option, + pub resets: Vec, +} + +impl PoolWindow { + /// The reset that restores something for the member at `member`. + pub fn reset_of(&self, member: usize) -> Option<&Reset> { + self.resets.iter().find(|reset| reset.member == member) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct Pool { + pub driver: String, + pub accounts: Vec, + pub windows: Vec, +} + +impl Pool { + /// `displayLimitWindows`. + pub fn display_windows(&self) -> Vec<&PoolWindow> { + let mut windows: Vec<&PoolWindow> = self.windows.iter().collect(); + match self.driver.as_str() { + "antigravity" => { + let family = |id: &str| { + ["-5h", "-weekly", "-quota"] + .iter() + .find_map(|suffix| id.strip_suffix(suffix)) + .unwrap_or(id) + .to_lowercase() + }; + windows.sort_by(|left, right| { + (!left.id.starts_with("gemini-")) + .cmp(&!right.id.starts_with("gemini-")) + .then_with(|| family(&left.id).cmp(&family(&right.id))) + .then_with(|| left.kind.cmp(&right.kind)) + }); + } + "cursor" => { + let has = |id: &str| self.windows.iter().any(|window| window.id == id); + let both = has("autoPercentUsed") && has("apiPercentUsed"); + windows.retain(|window| !both || window.id != "totalPercentUsed"); + windows.sort_by_key(|window| cursor_rank(&window.id)); + } + _ => {} + } + windows + } +} + +fn reset_millis(window: &Window) -> Option { + window.resets_at +} + +/// Codex ids name slots, so pool by kind; other drivers' ids identify distinct allowances. +fn pool_key(driver: &str, window: &Window) -> String { + if driver == "codex" { + window.kind.wire().to_owned() + } else { + format!("{}:{}", window.kind.wire(), window.id) + } +} + +/// `poolMember`: a spent weekly allowance blocks the session window, whatever it has left. +fn pool_member(account: &Account, reported: &Window, now: i64) -> (Window, f64) { + let spent_weekly = (reported.kind == Kind::Session) + .then(|| { + account.limits.windows.iter().find(|window| { + window.used_percent >= 100. + && match account.driver.as_str() { + "claudeAgent" => window.id == "seven_day", + "codex" => window.kind == Kind::Weekly, + _ => false, + } + }) + }) + .flatten(); + let Some(spent) = spent_weekly else { + return (reported.clone(), reported.used_percent); + }; + let mut resets_at = None; + let mut refill = 0.; + // A stale or missing clock cannot tell us when blocked quota becomes usable. + if let (Some(weekly), Some(session), Some(checked)) = ( + reset_millis(spent), + reset_millis(reported), + account.limits.checked_at, + ) { + let cutoff = now.max(checked); + if weekly > cutoff && session > cutoff { + if session <= weekly { + resets_at = spent.resets_at; + refill = 100.; + } else if reported.used_percent >= 100. { + resets_at = reported.resets_at; + refill = 100.; + } else { + resets_at = spent.resets_at; + refill = 100. - reported.used_percent.clamp(0., 100.); + } + } + } + ( + Window { + used_percent: 100., + resets_at, + duration_mins: None, + ..reported.clone() + }, + refill, + ) +} + +/// `poolWindows`. +fn pool_windows(accounts: &[Account], now: i64) -> Vec { + let mut by_key: Vec<(String, Vec)> = Vec::new(); + for (index, account) in accounts.iter().enumerate() { + for reported in &account.limits.windows { + let (window, refill_percent) = pool_member(account, reported, now); + let pooled = pool_key(&account.driver, &window); + let taken = by_key + .iter() + .find(|(key, _)| *key == pooled) + .is_some_and(|(_, members)| members.iter().any(|member| member.account == index)); + let key = if taken { + format!("{}:{}", window.kind.wire(), window.id) + } else { + pooled + }; + let member = Member { + account: index, + window, + refill_percent, + }; + match by_key.iter_mut().find(|(seen, _)| *seen == key) { + Some((_, members)) => members.push(member), + None => by_key.push((key, vec![member])), + } + } + } + let mut windows: Vec = by_key + .into_iter() + .map(|(key, members)| { + let count = members.len() as f64; + let first = &members[0].window; + let codex = accounts[members[0].account].driver == "codex"; + let used = members.iter().map(|m| m.window.used_percent).sum::() / count; + // Judge pace only for windows with reset clocks; others add spend without elapsed time. + let timed: Vec<(f64, f64)> = members + .iter() + .filter_map(|m| Some((m.window.used_percent, m.window.elapsed_share(now)?))) + .collect(); + let pace = (!timed.is_empty()).then(|| { + let total = timed.len() as f64; + Pace::of_shares( + timed.iter().map(|(used, _)| used).sum::() / total, + timed.iter().map(|(_, elapsed)| elapsed).sum::() / total, + ) + }); + let mut resets: Vec = members + .iter() + .enumerate() + .filter_map(|(member, entry)| { + Some(Reset { + member, + at: reset_millis(&entry.window)?, + restores_percent: (entry.refill_percent / count).round() as i64, + }) + }) + .collect(); + resets.sort_by_key(|reset| reset.at); + let mut slots: HashMap = HashMap::new(); + for (index, member) in members.iter().enumerate() { + slots.insert(member.account, index); + } + PoolWindow { + id: if codex { key } else { first.id.clone() }, + kind: first.kind, + label: first.label.clone(), + columns: (0..accounts.len()) + .map(|account| slots.get(&account).copied()) + .collect(), + used_percent: used.round() as i64, + remaining_percent: (100. - used).round() as i64, + pace, + resets, + members, + } + }) + .collect(); + windows.sort_by_key(|window| window.kind); + windows +} + +/// `collectLimitPools`: accounts grouped by driver, soonest reset first. +pub fn collect_pools(accounts: &[Account], now: i64) -> Vec { + let mut drivers: Vec<(&str, Vec<&Account>)> = Vec::new(); + for account in accounts { + match drivers + .iter_mut() + .find(|(driver, _)| *driver == account.driver) + { + Some((_, members)) => members.push(account), + None => drivers.push((&account.driver, vec![account])), + } + } + drivers + .into_iter() + .map(|(driver, members)| { + let order_window = members + .iter() + .flat_map(|account| &account.limits.windows) + .min_by_key(|window| window.kind); + let order_reset = |account: &Account| -> f64 { + let reset = order_window + .and_then(|order| { + account + .limits + .windows + .iter() + .find(|window| pool_key(driver, window) == pool_key(driver, order)) + }) + .and_then(|window| reset_millis(&pool_member(account, window, now).0)); + reset.map_or(f64::INFINITY, |at| at as f64) + }; + let mut sorted: Vec = members.into_iter().cloned().collect(); + sorted.sort_by(|left, right| { + order_reset(left) + .partial_cmp(&order_reset(right)) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| left.sort_name().cmp(&right.sort_name())) + .then_with(|| left.key.cmp(&right.key)) + }); + let windows = pool_windows(&sorted, now); + Pool { + driver: driver.to_owned(), + accounts: sorted, + windows, + } + }) + .collect() +} + +/// `accountInitials`. +pub fn account_initials(email: &str) -> String { + let mut parts = email.split('@'); + let first = |part: Option<&str>| part.and_then(|part| part.chars().next()); + let initials: String = [first(parts.next()), first(parts.next())] + .into_iter() + .flatten() + .collect::() + .to_uppercase(); + if initials.is_empty() { + "?".to_owned() + } else { + initials + } +} + +/// `accountHue`. +pub fn account_hue(email: &str) -> u32 { + let mut hash: i32 = 0; + for unit in email.encode_utf16() { + hash = hash.wrapping_mul(31).wrapping_add(i32::from(unit)); + } + hash.unsigned_abs() % 360 +} + +/// `resetCreditsSummary`. +pub fn reset_credits_summary(credits: &ResetCredits, now: i64, compact: bool) -> String { + let expires_in = credits.next_expires_at.map(|at| format_duration(at - now)); + if credits.available == 0 { + return "No reset credits banked".to_owned(); + } + if compact { + return match expires_in { + Some(expires) => format!("{} banked · expires in {expires}", credits.available), + None => format!("{} banked", credits.available), + }; + } + let noun = if credits.available == 1 { + "reset credit" + } else { + "reset credits" + }; + match expires_in { + Some(expires) => format!( + "{} {noun} banked · next expires in {expires}", + credits.available + ), + None => format!("{} {noun} banked", credits.available), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const HOUR: i64 = 3_600_000; + const NOW: i64 = 1_000 * HOUR; + + fn iso(at: i64) -> String { + let secs = at / 1000; + let (days, rest) = (secs.div_euclid(86_400), secs.rem_euclid(86_400)); + // Civil date from days since the epoch (Hinnant). + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let day = doy - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = yoe + era * 400 + i64::from(month <= 2); + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}.000Z", + rest / 3600, + rest % 3600 / 60, + rest % 60 + ) + } + + fn window(id: &str, kind: &str, used: f64, resets: Option, mins: Option) -> Value { + let mut value = json!({"id": id, "kind": kind, "label": id, "usedPercent": used}); + if let Some(at) = resets { + value["resetsAt"] = json!(iso(at)); + } + if let Some(mins) = mins { + value["windowDurationMins"] = json!(mins); + } + value + } + + use serde_json::Value; + + fn limits(checked: i64, windows: Vec) -> Limits { + Limits::decode(&json!({"checkedAt": iso(checked), "windows": windows})).expect("limits") + } + + fn native(instance: &str, driver: &str, email: Option<&str>, limits: Limits) -> Native { + Native { + instance_id: instance.to_owned(), + driver: driver.to_owned(), + display_name: Some(instance.to_owned()), + accent_color: None, + status: "ready".to_owned(), + enabled: true, + installed: true, + available: true, + authenticated: true, + email: email.map(str::to_owned), + plan: None, + workspace_id: None, + sharing: false, + limits: Some(limits), + } + } + + fn session(used: f64, resets: i64) -> Value { + window("five_hour", "session", used, Some(resets), Some(300.)) + } + + #[test] + fn instances_of_one_login_merge_into_one_account() { + let older = limits(NOW - HOUR, vec![session(10., NOW + HOUR)]); + let mut newer = limits(NOW, vec![session(40., NOW + HOUR)]); + newer.reset_credits = Some(ResetCredits { + available: 2, + next_expires_at: None, + }); + let mut first = native("a", "codex", Some("Me@x.io"), older); + first.plan = Some("Plus".to_owned()); + let second = native("b", "codex", Some("me@x.io"), newer); + let accounts = collect_accounts(&[first, second]); + assert_eq!(accounts.len(), 1); + let account = &accounts[0]; + assert_eq!(account.display_name.as_deref(), Some("a")); + assert_eq!(account.plan.as_deref(), Some("Plus")); + assert_eq!(account.limits.windows[0].used_percent, 40.); + assert_eq!(account.redeem.as_deref(), Some("b")); + assert_eq!( + account.limits.reset_credits.as_ref().map(|c| c.available), + Some(2) + ); + } + + #[test] + fn accounts_skip_providers_without_usable_limits() { + let usable = limits(NOW, vec![session(10., NOW + HOUR)]); + let empty = limits(NOW, vec![]); + let mut disabled = native("c", "codex", Some("c@x.io"), usable.clone()); + disabled.enabled = false; + let mut unavailable = native("d", "codex", Some("d@x.io"), usable.clone()); + unavailable.available = false; + let natives = [ + native("a", "codex", Some("a@x.io"), usable), + native("b", "codex", Some("b@x.io"), empty), + disabled, + unavailable, + ]; + let accounts = collect_accounts(&natives); + assert_eq!(accounts.len(), 1); + assert_eq!(collect_notices(&natives), ["b: No limits reported."]); + } + + #[test] + fn an_unreadable_workspace_names_the_login_s_other_account() { + let usable = limits(NOW, vec![session(10., NOW + HOUR)]); + let mut work = native("work", "codex", Some("me@x.io"), usable.clone()); + work.workspace_id = Some("w1".to_owned()); + let personal = native("personal", "codex", Some("me@x.io"), usable); + let pair = [work.clone(), personal.clone()]; + let resolve = key_resolver(&pair); + assert_eq!(resolve(&work).as_deref(), Some("codex:me@x.io:w1")); + assert_eq!(resolve(&personal).as_deref(), Some("codex:me@x.io:w1")); + let mut other = personal.clone(); + other.workspace_id = Some("w2".to_owned()); + let trio = [work, other, personal.clone()]; + let resolve = key_resolver(&trio); + assert_eq!(resolve(&personal), None); + } + + #[test] + fn pools_sort_accounts_by_the_soonest_reset() { + let later = native( + "a", + "claudeAgent", + Some("a@x.io"), + limits(NOW, vec![session(10., NOW + 3 * HOUR)]), + ); + let sooner = native( + "b", + "claudeAgent", + Some("b@x.io"), + limits(NOW, vec![session(30., NOW + HOUR)]), + ); + let accounts = collect_accounts(&[later, sooner]); + let pools = collect_pools(&accounts, NOW); + assert_eq!(pools.len(), 1); + let pool = &pools[0]; + let names: Vec<_> = pool + .accounts + .iter() + .map(|a| a.display_name.as_deref()) + .collect(); + assert_eq!(names, [Some("b"), Some("a")]); + let window = &pool.windows[0]; + assert_eq!(window.remaining_percent, 80); + assert_eq!(window.columns, [Some(0), Some(1)]); + assert_eq!(window.resets.len(), 2); + assert_eq!(window.resets[0].at, NOW + HOUR); + } + + #[test] + fn a_spent_weekly_window_blocks_the_session_window() { + let weekly = window( + "seven_day", + "weekly", + 100., + Some(NOW + 20 * HOUR), + Some(10_080.), + ); + let spent = |session_used, session_resets| { + let account = Account { + key: "k".to_owned(), + driver: "claudeAgent".to_owned(), + display_name: None, + email: None, + plan: None, + accent_color: None, + redeem: None, + limits: limits( + NOW, + vec![ + window( + "five_hour", + "session", + session_used, + Some(session_resets), + Some(300.), + ), + weekly.clone(), + ], + ), + }; + let reported = account.limits.windows[0].clone(); + pool_member(&account, &reported, NOW) + }; + // The session refills first, so the weekly reset is when it becomes usable. + let (blocked, refill) = spent(20., NOW + 2 * HOUR); + assert_eq!(blocked.used_percent, 100.); + assert_eq!(blocked.duration_mins, None); + assert_eq!(blocked.resets_at, Some(NOW + 20 * HOUR)); + assert_eq!(refill, 100.); + // The session outlasts the weekly window and is itself spent. + let (blocked, refill) = spent(100., NOW + 30 * HOUR); + assert_eq!(blocked.resets_at, Some(NOW + 30 * HOUR)); + assert_eq!(refill, 100.); + // The session outlasts the weekly window with room left: only that room comes back. + let (blocked, refill) = spent(25., NOW + 30 * HOUR); + assert_eq!(blocked.resets_at, Some(NOW + 20 * HOUR)); + assert_eq!(refill, 75.); + // A session without a clock has no reset to wait for. + let account = Account { + limits: limits( + NOW, + vec![ + window("five_hour", "session", 5., None, None), + weekly.clone(), + ], + ), + key: "k".to_owned(), + driver: "claudeAgent".to_owned(), + display_name: None, + email: None, + plan: None, + accent_color: None, + redeem: None, + }; + let reported = account.limits.windows[0].clone(); + let (blocked, refill) = pool_member(&account, &reported, NOW); + assert_eq!((blocked.resets_at, refill), (None, 0.)); + } + + #[test] + fn pooled_windows_average_use_and_judge_pace_over_the_timed_ones() { + let half = |used| { + window( + "five_hour", + "session", + used, + Some(NOW + 150 * 60_000), + Some(300.), + ) + }; + let accounts = collect_accounts(&[ + native("a", "codex", Some("a@x.io"), limits(NOW, vec![half(20.)])), + native("b", "codex", Some("b@x.io"), limits(NOW, vec![half(80.)])), + ]); + let pools = collect_pools(&accounts, NOW); + let window = &pools[0].windows[0]; + assert_eq!(window.used_percent, 50); + assert_eq!(window.remaining_percent, 50); + assert_eq!(window.pace, Some(Pace::On)); + assert_eq!(window.id, "session"); + } + + #[test] + fn windows_order_by_kind_then_reporting_order() { + let accounts = collect_accounts(&[native( + "a", + "claudeAgent", + Some("a@x.io"), + limits( + NOW, + vec![ + window("seven_day", "weekly", 10., None, None), + window("five_hour", "session", 10., None, None), + ], + ), + )]); + let pools = collect_pools(&accounts, NOW); + let ids: Vec<_> = pools[0].windows.iter().map(|w| w.id.as_str()).collect(); + assert_eq!(ids, ["five_hour", "seven_day"]); + } + + fn pool_of(driver: &str, ids: &[(&str, &str)]) -> Pool { + let windows = ids + .iter() + .map(|(id, kind)| window(id, kind, 10., None, None)) + .collect(); + let accounts = + collect_accounts(&[native("a", driver, Some("a@x.io"), limits(NOW, windows))]); + collect_pools(&accounts, NOW).remove(0) + } + + #[test] + fn cursor_hides_overall_when_both_allowances_exist() { + let pool = pool_of( + "cursor", + &[ + ("totalPercentUsed", "monthly"), + ("apiPercentUsed", "monthly"), + ("autoPercentUsed", "monthly"), + ], + ); + let ids: Vec<_> = pool + .display_windows() + .iter() + .map(|w| w.id.clone()) + .collect(); + assert_eq!(ids, ["autoPercentUsed", "apiPercentUsed"]); + let lone = pool_of("cursor", &[("totalPercentUsed", "monthly")]); + assert_eq!(lone.display_windows().len(), 1); + } + + #[test] + fn antigravity_lists_gemini_first_and_pairs_a_family() { + let pool = pool_of( + "antigravity", + &[ + ("claude-weekly", "weekly"), + ("gemini-weekly", "weekly"), + ("claude-5h", "session"), + ("gemini-5h", "session"), + ], + ); + let ids: Vec<_> = pool + .display_windows() + .iter() + .map(|w| w.id.clone()) + .collect(); + assert_eq!( + ids, + ["gemini-5h", "gemini-weekly", "claude-5h", "claude-weekly"] + ); + } + + #[test] + fn external_links_group_accounts_by_url() { + let mut shared = limits(NOW, vec![session(10., NOW + HOUR)]); + shared.external = Some(crate::usage::External { + label: "Usage".to_owned(), + url: CHATGPT_USAGE_URL.to_owned(), + }); + let natives = [ + native("a", "codex", Some("a@x.io"), shared.clone()), + native("b", "codex", Some("b@x.io"), shared), + ]; + let links = collect_external_links(&natives, "Local"); + assert_eq!(links.len(), 1); + assert_eq!(links[0].accounts, ["a on Local", "b on Local"]); + } + + #[test] + fn avatars_follow_the_web_hash() { + assert_eq!(account_initials("jo@x.io"), "JX"); + assert_eq!(account_initials("@"), "?"); + assert_eq!(account_initials("solo"), "S"); + assert_eq!(account_hue(""), 0); + // 97 * 31 + 98 = 3105; 3105 % 360 = 225. + assert_eq!(account_hue("ab"), 225); + let long = "a-very-long-address@example-domain.com"; + assert!(account_hue(long) < 360); + } + + #[test] + fn reset_credit_summaries_name_the_next_expiry() { + let credits = |available, expires| ResetCredits { + available, + next_expires_at: expires, + }; + assert_eq!( + reset_credits_summary(&credits(0, None), NOW, false), + "No reset credits banked" + ); + assert_eq!( + reset_credits_summary(&credits(1, None), NOW, false), + "1 reset credit banked" + ); + assert_eq!( + reset_credits_summary(&credits(3, Some(NOW + 26 * HOUR)), NOW, false), + "3 reset credits banked · next expires in 1d 2h" + ); + assert_eq!( + reset_credits_summary(&credits(3, Some(NOW + 2 * HOUR)), NOW, true), + "3 banked · expires in 2h 0m" + ); + } + + #[test] + fn chatgpt_sharing_needs_an_authenticated_login() { + let mut shared = native("a", "codex", None, limits(NOW, vec![])); + shared.sharing = true; + assert!(shared.uses_chatgpt_sharing()); + shared.authenticated = false; + assert!(!shared.uses_chatgpt_sharing()); + } +} From afd7122283381f83b28b3afb4499d0be959bd284 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 8 Nov 2026 08:18:19 -0500 Subject: [PATCH 59/82] fix(desktop-gpui): disabled git quick action no longer crashes the details card A disabled quick action set a second hover style on the primary segment, which gpui rejects with a panic on render. The segment now takes an enabled flag and only sets hover when enabled. --- apps/desktop-gpui/src/details_card.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/desktop-gpui/src/details_card.rs b/apps/desktop-gpui/src/details_card.rs index 118ad94931a1..042c7d4ed7c7 100644 --- a/apps/desktop-gpui/src/details_card.rs +++ b/apps/desktop-gpui/src/details_card.rs @@ -720,6 +720,7 @@ fn primary_segment( id: &'static str, icon: impl IntoElement, label: String, + enabled: bool, cx: &App, ) -> gpui::Stateful { let theme = cx.theme(); @@ -732,8 +733,10 @@ fn primary_segment( .px(px(10.)) .gap(px(10.)) .rounded_l(px(8.)) - .cursor_pointer() - .hover(move |style| style.bg(fill)) + .when(enabled, |segment| { + segment.cursor_pointer().hover(move |style| style.bg(fill)) + }) + .when(!enabled, |segment| segment.opacity(0.5).cursor_default()) .text_size(px(13.)) .font_weight(FontWeight::MEDIUM) .text_color(foreground.opacity(0.8)) @@ -810,7 +813,7 @@ impl DetailsCard { Some(id) => editor_mark(id, cx), None => row_icon(Icons::SquareArrowOutUpRight, cx).into_any_element(), }; - let primary = primary_segment("open-in-editor", icon, label, cx) + let primary = primary_segment("open-in-editor", icon, label, true, cx) .on_click(cx.listener(|this, _, _, cx| this.open_favorite(cx))); let editors = editors.to_vec(); let open = self.editor_open; @@ -1028,9 +1031,9 @@ impl DetailsCard { quick.hint.clone() }; let icon = row_icon(quick_icon(quick.kind, &quick.label), cx); - let mut primary = primary_segment("git-quick-action", icon, quick.label.clone(), cx); + let mut primary = + primary_segment("git-quick-action", icon, quick.label.clone(), !disabled, cx); if disabled { - primary = primary.opacity(0.5).cursor_default().hover(|style| style); if let Some(hint) = hint { primary = primary.tooltip(tip(hint)); } From 26d871abade80265af2ab9d12dafae89c5000264 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 8 Nov 2026 08:48:32 -0500 Subject: [PATCH 60/82] feat(desktop-gpui): usage pickers, model prices dialog and limits parity The usage modal had no environment or provider pickers, no way to set custom model prices, and a Limits tab that drifted from the web. It now has the breadcrumb pickers (provider selection persists as hiddenProviders and filters every tab), the Custom model prices dialog saving usagePriceOverrides and usageModelAliases, and Limits matched to the web on spacing, line heights, hatching, pace tick clamping, legend rows, popover padding and open state, the notices alert, and JS rounding. --- apps/desktop-gpui/src/assets.rs | 2 + apps/desktop-gpui/src/connection.rs | 2 +- apps/desktop-gpui/src/main.rs | 1 + apps/desktop-gpui/src/providers.rs | 28 +- apps/desktop-gpui/src/status.rs | 2 +- apps/desktop-gpui/src/ui.rs | 2 +- apps/desktop-gpui/src/ui/usage_dialog.rs | 85 +- .../src/ui/usage_dialog/limits.rs | 490 ++++-- .../src/ui/usage_dialog/pickers.rs | 441 ++++++ .../src/ui/usage_dialog/prices.rs | 1374 +++++++++++++++++ apps/desktop-gpui/src/usage.rs | 18 +- apps/desktop-gpui/src/usage_data.rs | 146 +- apps/desktop-gpui/src/usage_limits.rs | 6 +- apps/desktop-gpui/src/usage_prices.rs | 942 +++++++++++ 14 files changed, 3343 insertions(+), 196 deletions(-) create mode 100644 apps/desktop-gpui/src/ui/usage_dialog/pickers.rs create mode 100644 apps/desktop-gpui/src/ui/usage_dialog/prices.rs create mode 100644 apps/desktop-gpui/src/usage_prices.rs diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 5262b17bd566..86dca7197943 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -65,6 +65,8 @@ gpui_kit_assets::icon_assets!( SquareSplitHorizontal, SquareSplitVertical, Plus, + SlidersHorizontal, + RotateCcw, Trash, Square, SquareTerminal, diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 39b5e59a20b0..491333b38c42 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -1118,7 +1118,7 @@ fn publish_diff(cwd: String, event: Event, status: &StatusSender) { fn publish_catalog(event: Event, status: &StatusSender) { match event { Event::Done { value, .. } => match Catalog::decode(value) { - Ok(catalog) => publish(status, Status::Providers(catalog)), + Ok(catalog) => publish(status, Status::Providers(Box::new(catalog))), Err(error) => eprintln!("[rpc] unreadable provider config: {error}"), }, Event::Failed { message, .. } => eprintln!("[rpc] could not load providers: {message}"), diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index fb4bf866e7a2..1b67bf93d12b 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -37,6 +37,7 @@ mod upload; mod usage; mod usage_data; mod usage_limits; +mod usage_prices; mod wire; use assets::AppAssets; diff --git a/apps/desktop-gpui/src/providers.rs b/apps/desktop-gpui/src/providers.rs index aea006464814..7388a43a28f8 100644 --- a/apps/desktop-gpui/src/providers.rs +++ b/apps/desktop-gpui/src/providers.rs @@ -6,10 +6,11 @@ use crate::strip::Defaults; use crate::usage::Limits; use crate::usage_limits::Native; +use crate::usage_prices::{Price, Snapshot}; use serde::de::DeserializeOwned; use serde::{Deserialize, Deserializer}; use serde_json::Value; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; const DEFAULT_ENABLED_OFF_DRIVERS: &[&str] = &[ "cursor", @@ -302,6 +303,11 @@ struct Settings { default_thread_env_mode: Option, #[serde(default)] new_worktrees_start_from_origin: Option, + /// `usagePriceOverrides`; `None` on servers that do not send it. + #[serde(default, deserialize_with = "lenient_option")] + usage_price_overrides: Option>, + #[serde(default, deserialize_with = "lenient_option")] + usage_model_aliases: Option>, } #[derive(Deserialize)] @@ -330,6 +336,8 @@ struct EnvironmentCapabilities { deserialize_with = "lenient_option" )] file_attachments: Option, + #[serde(default, rename = "usageModelAliases")] + usage_model_aliases: bool, } #[derive(Deserialize)] @@ -346,6 +354,8 @@ pub struct Catalog { available_editors: Vec, /// `environment.capabilities.fileAttachments.maxUploadBytes`. max_file_upload_bytes: Option, + /// `environment.capabilities.usageModelAliases`. + usage_model_aliases: bool, } /// The two-letter instance chip at the icon's bottom-right. @@ -442,12 +452,28 @@ impl Catalog { providers, settings, available_editors, + usage_model_aliases: environment + .as_ref() + .is_some_and(|environment| environment.capabilities.usage_model_aliases), max_file_upload_bytes: environment .and_then(|environment| environment.capabilities.file_attachments) .map(|files| files.max_upload_bytes), }) } + /// The environment's prices and mappings as the prices dialog reads them + /// (`priceTargetsAtom`): mappings only count when the server advertises them. + pub fn usage_prices(&self) -> Snapshot { + ( + self.settings.usage_price_overrides.clone(), + if self.usage_model_aliases { + self.settings.usage_model_aliases.clone() + } else { + None + }, + ) + } + /// What the server advertises for file attachments, as `attachments::staging_limit` takes it. pub fn file_upload_limit(&self) -> Option { self.max_file_upload_bytes diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index aa0706172dc5..02b9de201736 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -28,7 +28,7 @@ pub enum Status { /// Another saved environment's projects; says nothing about the primary connection. Environment(EnvironmentProjects), /// The primary connection's provider instances, for the composer's model picker. - Providers(Catalog), + Providers(Box), /// The selected thread's transcript, or why it could not be loaded. Thread { id: String, diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 28bcce154e46..8a0368e7e417 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -498,7 +498,7 @@ impl MainView { if let Status::Providers(catalog) = status { self.usage .update(cx, |usage, cx| usage.set_providers(&catalog, cx)); - self.providers = Some(catalog); + self.providers = Some(*catalog); return cx.notify(); } if let Status::Thread { id, transcript } = status { diff --git a/apps/desktop-gpui/src/ui/usage_dialog.rs b/apps/desktop-gpui/src/ui/usage_dialog.rs index b3b61e91fbcb..14cea6035559 100644 --- a/apps/desktop-gpui/src/ui/usage_dialog.rs +++ b/apps/desktop-gpui/src/ui/usage_dialog.rs @@ -1,11 +1,14 @@ //! The web's Usage page (`UsagePage.tsx`) as the `/usage` route modal (`AppRouteModal.tsx`). mod limits; +mod pickers; +mod prices; use super::*; use crate::usage_data::{ self as data, Merged, Metric, Prefs, Provider, Summary, Tint, Window as Span, Zone, }; +use crate::usage_prices::Change; use gpui::{ ClickEvent, ElementId, FocusHandle, KeyBinding, PathBuilder, ScrollHandle, Size, fill, point, radians, relative, size, @@ -86,6 +89,9 @@ enum Pending { key: (String, String), }, EnableCursor, + Prices { + changes: Vec, + }, } struct Shown { @@ -138,6 +144,11 @@ pub struct UsageDialog { next_token: u64, pending: HashMap, limits: LimitsState, + /// `selectedIds`: false once the one paired environment is unchecked. + env_selected: bool, + menu: Option, + prices: Option, + price_store: prices::PriceStore, } fn now_ms() -> i64 { @@ -173,6 +184,10 @@ impl UsageDialog { next_token: TOKEN_BASE, pending: HashMap::new(), limits: LimitsState::default(), + env_selected: true, + menu: None, + prices: None, + price_store: prices::PriceStore::default(), } } @@ -286,6 +301,7 @@ impl UsageDialog { Pending::Limits => self.limits_settled(event), Pending::Redeem { key } => self.redeem_settled(key, event), Pending::EnableCursor => self.enable_settled(event), + Pending::Prices { changes } => self.prices_settled(changes, event), Pending::Rates { generation } => { if generation == self.generation { self.start_query(); @@ -305,7 +321,7 @@ impl UsageDialog { }; match summary { Some(summary) => { - let merged = data::merge(&summary); + let merged = data::merge(&summary.without(self.prefs.hidden)); let again = !awaiting && summary.refreshing(); self.shown = Some(Shown { span, @@ -335,6 +351,17 @@ impl UsageDialog { cx.notify(); } + fn close_or_prices(&mut self, window: &mut Window, cx: &mut Context) { + if self.prices.is_some() { + self.close_prices(cx); + } else if self.menu.is_some() { + self.menu = None; + cx.notify(); + } else { + self.close(window, cx); + } + } + fn select_window(&mut self, days: u32, cx: &mut Context) { self.prefs.window_days = days; self.prefs.save(); @@ -395,13 +422,19 @@ impl UsageDialog { Stage::First => Dim::All, Stage::Awaiting => { let mut providers: Vec = Vec::new(); + let mut any = false; for source in shown.summary.sources.iter().filter(|s| s.refreshing) { - if !providers.contains(&source.provider) { + any = true; + if !self.prefs.hidden.contains(source.provider) + && !providers.contains(&source.provider) + { providers.push(source.provider); } } - if providers.is_empty() { + if !any { Dim::All + } else if providers.is_empty() { + Dim::None } else { Dim::Providers(providers) } @@ -478,28 +511,6 @@ fn toggle_group(cx: &App) -> gpui::Div { .bg(cx.theme().input.opacity(0.4)) } -/// The breadcrumb's picker triggers: label plus a `size-3.5` chevron. -fn filter_label(label: &'static str, alert: bool, cx: &App) -> gpui::Div { - let theme = cx.theme(); - h_flex() - .gap_1() - .items_center() - .text_color(theme.foreground) - .when(alert, |row| { - row.child( - Icon::new(Icons::CircleAlert) - .size(px(14.)) - .text_color(theme.warning), - ) - }) - .child(label) - .child( - Icon::new(Icons::ChevronDown) - .size(px(14.)) - .text_color(theme.muted_foreground), - ) -} - /// `Button` `ghost` `icon-sm` without a tooltip, which the web's refresh button does not have. fn plain_icon_button( id: &'static str, @@ -567,6 +578,7 @@ impl Render for UsageDialog { .background .opacity(if theme.is_dark() { 0.64 } else { 0.6 }); let (border, background) = (theme.border, theme.background); + let prices = self.prices_dialog(window, cx); let header = self.header(width, cx); let body = self.body(vw, cx); let confirm = self.reset_credit_dialog(cx); @@ -575,7 +587,9 @@ impl Render for UsageDialog { .inset_0() .key_context(CONTEXT) .track_focus(&self.focus) - .on_action(cx.listener(|this, _: &CloseUsage, window, cx| this.close(window, cx))) + .on_action( + cx.listener(|this, _: &CloseUsage, window, cx| this.close_or_prices(window, cx)), + ) .on_action( cx.listener(|this, _: &ShowCost, _, cx| this.select_metric(Metric::Cost, cx)), ) @@ -628,6 +642,7 @@ impl Render for UsageDialog { ), ) .children(confirm) + .children(prices) .into_any_element() } } @@ -637,7 +652,6 @@ impl UsageDialog { let theme = cx.theme(); let (muted, border) = (theme.muted_foreground, theme.border); let limits = self.prefs.metric == Metric::Limits; - let alert = !limits && self.failed && self.shown.is_none(); let windows: [(u32, &'static str, &'static str); 4] = [ (1, "Past 24h", "\u{21e7}\u{2318}1"), (7, "7 days", "\u{21e7}\u{2318}2"), @@ -740,9 +754,9 @@ impl UsageDialog { .font_weight(FontWeight::MEDIUM) .child(div().text_color(muted).child("Usage")) .child(div().text_color(muted).child("/")) - .child(filter_label("All environments", alert, cx)) + .child(self.environment_picker(cx)) .child(div().text_color(muted).child("/")) - .child(filter_label("All providers", false, cx)), + .child(self.provider_picker(cx)), ) .children(window_label.map(|label| { div() @@ -776,6 +790,12 @@ impl UsageDialog { .text_color(muted) .child(format!("Connect an environment to see {noun}.")) .into_any_element() + } else if !self.env_selected { + div() + .text_sm() + .text_color(muted) + .child(format!("Select an environment to see {noun}.")) + .into_any_element() } else if limits { self.limits_body(vw, cx) } else if self.access == Access::Offline { @@ -996,7 +1016,12 @@ impl UsageDialog { let (muted, foreground) = (theme.muted_foreground, theme.foreground); let wide = vw >= 1024.; - let mut notes = data::source_messages(sources); + let visible: Vec<_> = sources + .iter() + .filter(|source| !self.prefs.hidden.contains(source.provider)) + .cloned() + .collect(); + let mut notes = data::source_messages(&visible); if let Some(mismatch) = merged.mismatch { notes.push(data::contract_mismatch_message( "This environment", diff --git a/apps/desktop-gpui/src/ui/usage_dialog/limits.rs b/apps/desktop-gpui/src/ui/usage_dialog/limits.rs index 9101d6d51c45..1db21283b699 100644 --- a/apps/desktop-gpui/src/ui/usage_dialog/limits.rs +++ b/apps/desktop-gpui/src/ui/usage_dialog/limits.rs @@ -7,7 +7,7 @@ use crate::usage_limits::{ self as limits, Account, CHATGPT_USAGE_URL, Link, Native, Pool, PoolWindow, cursor_window_details, }; -use gpui::{Anchor, StyleRefinement, WeakEntity, pattern_slash}; +use gpui::{Anchor, StyleRefinement, WeakEntity, canvas, fill, pattern_slash}; use gpui_component::hover_card::HoverCard; /// `CURSOR_KEYCHAIN_COPY`. @@ -28,7 +28,7 @@ pub(super) struct Target { #[derive(Default)] pub(super) struct LimitsState { natives: Vec, - environment: String, + pub(super) environment: String, cursor_ready: bool, pub(super) now: i64, pending: bool, @@ -39,6 +39,8 @@ pub(super) struct LimitsState { busy: Option<(String, String)>, status: HashMap<(String, String), String>, enabling: bool, + /// The segment whose popover is open, for the trigger ring. + open_segment: Option, } impl LimitsState { @@ -61,6 +63,7 @@ impl UsageDialog { pub fn set_providers(&mut self, catalog: &Catalog, cx: &mut Context) { self.limits.natives = catalog.natives(); self.limits.cursor_ready = catalog.cursor_ready(); + self.price_store.refresh(catalog.usage_prices()); if self.open && self.prefs.metric == Metric::Limits { self.limits.now = now_ms(); } @@ -169,16 +172,41 @@ impl UsageDialog { let Some(shown) = &self.shown else { return false; }; - let sources = &shown.summary.sources; + if self.prefs.hidden.contains(Provider::Cursor) { + return false; + } + let sources: Vec<_> = shown + .summary + .sources + .iter() + .filter(|source| !self.prefs.hidden.contains(source.provider)) + .collect(); let has_account = sources .iter() .any(|source| source.provider == Provider::Cursor && source.host_id == "cursor.com"); !has_account && self.limits.cursor_ready && sources.iter().any(|source| source.has_action) } + /// The natives of the providers the breadcrumb has not hidden. + fn shown_natives(&self) -> Vec { + let hidden: Vec<&str> = Provider::ORDER + .iter() + .filter(|provider| self.prefs.hidden.contains(**provider)) + .map(|provider| provider.driver_kind()) + .collect(); + self.limits + .natives + .iter() + .filter(|native| !hidden.contains(&native.driver.as_str())) + .cloned() + .collect() + } + /// The overview's `showChatGptSharing`. pub(super) fn chatgpt_sharing(&self) -> bool { - self.limits.natives.iter().any(Native::uses_chatgpt_sharing) + self.shown_natives() + .iter() + .any(Native::uses_chatgpt_sharing) } pub(super) fn cursor_enable_row(&self, cx: &mut Context) -> Option { @@ -194,7 +222,8 @@ impl UsageDialog { .items_baseline() .justify_between() .gap_4() - .text_sm() + .text_size(px(14.)) + .line_height(px(20.)) .child( h_flex() .min_w_0() @@ -228,7 +257,8 @@ impl UsageDialog { .items_center() .justify_between() .gap_2() - .text_xs() + .text_size(px(12.)) + .line_height(px(16.)) .text_color(theme.muted_foreground) .child("ChatGPT shared usage") .child(manage_usage_button( @@ -242,10 +272,11 @@ impl UsageDialog { pub(super) fn limits_body(&self, vw: f32, cx: &mut Context) -> AnyElement { let st = &self.limits; let now = st.now; - let accounts = limits::collect_accounts(&st.natives); + let natives = self.shown_natives(); + let accounts = limits::collect_accounts(&natives); let pools = limits::collect_pools(&accounts, now); - let notices = limits::collect_notices(&st.natives); - let links = limits::collect_external_links(&st.natives, &st.environment); + let notices = limits::collect_notices(&natives); + let links = limits::collect_external_links(&natives, &st.environment); let prompt = self.cursor_prompt_needed(); let theme = cx.theme(); let muted = theme.muted_foreground; @@ -274,10 +305,13 @@ impl UsageDialog { let mut page = v_flex().gap_8(); if pools.is_empty() && notices.is_empty() && !prompt && links.is_empty() { - page = - page.child(div().text_sm().text_color(muted).child( - "No provider on the selected environments reports subscription limits.", - )); + page = page.child( + div() + .text_size(px(14.)) + .line_height(px(20.)) + .text_color(muted) + .child("No provider on the selected environments reports subscription limits."), + ); } let weak = cx.weak_entity(); for (index, pool) in pools.iter().enumerate() { @@ -308,7 +342,8 @@ impl UsageDialog { h_flex() .items_center() .gap_2() - .text_sm() + .text_size(px(14.)) + .line_height(px(20.)) .font_weight(FontWeight::MEDIUM) .child(provider_mark_at( &Mark { @@ -333,7 +368,8 @@ impl UsageDialog { .p_4() .child( div() - .text_xs() + .text_size(px(12.)) + .line_height(px(16.)) .text_color(theme.muted_foreground) .child(CURSOR_KEYCHAIN_COPY), ) @@ -347,7 +383,8 @@ impl UsageDialog { .rounded(px(6.)) .border_1() .border_color(theme.border) - .text_sm() + .text_size(px(14.)) + .line_height(px(20.)) .font_weight(FontWeight::MEDIUM) .when(enabling, |button| button.opacity(0.64)) .when(!enabling, |button| { @@ -377,7 +414,8 @@ impl UsageDialog { let heading = h_flex() .items_center() .gap_2() - .text_sm() + .text_size(px(14.)) + .line_height(px(20.)) .font_weight(FontWeight::MEDIUM) .child(provider_mark_at( &Mark { @@ -434,7 +472,7 @@ impl UsageDialog { cx: &mut Context, ) -> AnyElement { let theme = cx.theme(); - let (muted, foreground, border) = (theme.muted_foreground, theme.foreground, theme.border); + let (muted, border) = (theme.muted_foreground, theme.border); let cursor = if pool.driver == "cursor" { cursor_window_details(&window.id) } else { @@ -465,14 +503,15 @@ impl UsageDialog { .gap_1() .child( div() - .text_sm() + .text_size(px(14.)) + .line_height(px(20.)) .font_weight(FontWeight::MEDIUM) .child(label.to_owned()), ) .child( h_flex() .items_baseline() - .gap_1() + .gap_2() .child( div() .text_size(px(30.)) @@ -480,12 +519,21 @@ impl UsageDialog { .font_weight(FontWeight::SEMIBOLD) .child(format!("{}%", window.remaining_percent)), ) - .child(div().text_sm().text_color(muted).child("left")) - .children(pace), + .child( + h_flex() + .items_center() + .gap_2() + .text_size(px(14.)) + .line_height(px(20.)) + .text_color(muted) + .child("left") + .children(pace), + ), ) .children(next_refill.filter(|_| columns > 1).map(|percent| { div() - .text_xs() + .text_size(px(12.)) + .line_height(px(16.)) .font_weight(FontWeight::MEDIUM) .child(format!("\u{21bb} +{percent}%")) })); @@ -500,7 +548,9 @@ impl UsageDialog { .child( div() .flex() - .when(two_columns, |row| row.flex_row().gap(px(24.))) + .when(two_columns, |row| { + row.flex_row().items_center().gap(px(24.)) + }) .when(!two_columns, |row| row.flex_col().gap_3()) .child( div() @@ -509,8 +559,13 @@ impl UsageDialog { ) .child(div().flex_1().min_w_0().child(bar)), ) - .children(description.map(|text| div().text_xs().text_color(muted).child(text))) - .children(self.redeem_statuses(pool, window, foreground)) + .children(description.map(|text| { + div() + .text_size(px(12.)) + .line_height(px(16.)) + .text_color(muted) + .child(text) + })) .into_any_element() } @@ -518,8 +573,9 @@ impl UsageDialog { &self, pool: &Pool, window: &PoolWindow, - foreground: Hsla, + theme: &gpui_component::Theme, ) -> Vec { + let (foreground, muted) = (theme.foreground, theme.muted_foreground); let mut rows = Vec::new(); for member in &window.members { let account = &pool.accounts[member.account]; @@ -532,13 +588,18 @@ impl UsageDialog { }; if let Some(text) = text { rows.push( - div() - .text_xs() + h_flex() + .flex_wrap() + .items_center() + .gap(px(4.)) + .text_size(px(12.)) + .line_height(px(16.)) + .text_color(muted) .child( div() .font_weight(FontWeight::MEDIUM) .text_color(foreground) - .child(format!("{} ", account_name_text(account, &pool.driver))), + .child(account_name_for_status(account, &pool.driver)), ) .child(text) .into_any_element(), @@ -562,18 +623,20 @@ impl UsageDialog { let columns = window.columns.len(); let wide = env.bar_width >= WIDE_BAR; let show_name = columns > 1; - let mut present = 0usize; + let segment_width = segment_width(env.bar_width, columns); let mut cells: Vec = Vec::new(); let mut legend: Vec = Vec::new(); for (column, slot) in window.columns.iter().enumerate() { let Some(member_index) = *slot else { cells.push(div().flex_1().min_w_0().into_any_element()); + if !wide { + legend.push(div().h_0().into_any_element()); + } continue; }; - present += 1; let part = Segment::new( pool_index * 1_000_000 + window_index * 1_000 + column, - present, + column + 1, pool, window, member_index, @@ -586,17 +649,18 @@ impl UsageDialog { div() .flex_1() .min_w_0() - .child(part.bar(wide, show_name, env.bar_width / columns as f32, cx)) + .child(part.bar(wide, show_name, segment_width, cx)) .into_any_element(), ); if !wide { - legend.push(part.legend_row(show_name, cx)); + legend.push(part.legend_row(cx)); } } v_flex() .gap_1() .child(h_flex().gap_1().children(cells)) - .children((!legend.is_empty()).then(|| v_flex().gap_0p5().children(legend))) + .children(legend) + .children(self.redeem_statuses(pool, window, cx.theme())) .into_any_element() } @@ -615,7 +679,8 @@ impl UsageDialog { .rounded(px(6.)) .border_1() .border_color(border) - .text_sm() + .text_size(px(14.)) + .line_height(px(20.)) .font_weight(FontWeight::MEDIUM) .cursor_pointer() .hover(|style| style.bg(theme.foreground.opacity(0.06))) @@ -633,7 +698,8 @@ impl UsageDialog { .rounded(px(6.)) .bg(theme.primary) .text_color(theme.primary_foreground) - .text_sm() + .text_size(px(14.)) + .line_height(px(20.)) .font_weight(FontWeight::MEDIUM) .cursor_pointer() .on_click(cx.listener(|this, _, _, cx| this.redeem(cx))) @@ -682,7 +748,7 @@ impl UsageDialog { .font_weight(FontWeight::SEMIBOLD) .child("Use a reset credit?"), ) - .child(div().text_sm().text_color(muted).child( + .child(div().text_size(px(14.)).line_height(px(20.)).text_color(muted).child( "This redeems one credit on your account and clears the current rate-limit windows. It cannot be undone.", )), ) @@ -726,6 +792,59 @@ fn account_name_text(account: &Account, driver: &str) -> String { driver_label(driver) } +/// Tracks which segment's popover is open. +fn open_change( + weak: WeakEntity, + id: usize, +) -> impl Fn(&bool, &mut Window, &mut App) + 'static { + move |open, _, cx| { + weak.update(cx, |this, cx| { + let state = &mut this.limits.open_segment; + if *open { + *state = Some(id); + } else if *state == Some(id) { + *state = None; + } + cx.notify(); + }) + .ok(); + } +} + +/// `AccountName` in a status line: the name, or the email chip. +fn account_name_for_status(account: &Account, driver: &str) -> AnyElement { + account_name(account, driver) +} + +/// Pixel width of one segment: columns share the bar minus a 4px gap between each. +fn segment_width(bar_width: f32, columns: usize) -> f32 { + let columns = columns.max(1) as f32; + (bar_width - 4. * (columns - 1.)) / columns +} + +/// Center of the pace tick in px: `clamp(3px, T%, 100% - 3px)` of the segment. +fn pace_mark_center(left_percent: i64, width: f32) -> f32 { + (left_percent as f32 / 100. * width).clamp(3., (width - 3.).max(3.)) +} + +/// `repeating-linear-gradient(135deg, c 0 1px, transparent 1px 5px)`: the shader's +/// stripe sizes are device pixels, so scale a 1px stripe on a 5px period by the display scale. +fn hatch_pattern(color: Hsla) -> impl IntoElement { + canvas( + |_, _, _| (), + move |bounds, _, window, _| { + let scale = window.scale_factor(); + let period = 5.0_f32 * scale; + let thickness = 1.0_f32 * scale; + let diagonal = std::f32::consts::SQRT_2; + let width = thickness * diagonal; + let interval = (period - thickness) * diagonal; + window.paint_quad(fill(bounds, pattern_slash(color, width, interval))); + }, + ) + .size_full() +} + /// `oklch(l c h)` as an sRGB color. fn oklch(lightness: f32, chroma: f32, hue: f32) -> Hsla { let (a, b) = ( @@ -835,6 +954,7 @@ struct Segment { reveal: bool, key: (String, String), busy: bool, + open: bool, weak: WeakEntity, } @@ -860,9 +980,9 @@ impl Segment { .and_then(normalize_accent) .map_or(pool_color, |accent| rgb(accent).into()); let credits = account - .redeem + .limits + .reset_credits .as_ref() - .and(account.limits.reset_credits.as_ref()) .map_or(0, |credits| credits.available); let key = (account.key.clone(), member.window.id.clone()); Self { @@ -882,6 +1002,7 @@ impl Segment { environment: env.environment.clone(), reveal: env.reveal, busy: dialog.limits.busy.as_ref() == Some(&key), + open: dialog.limits.open_segment == Some(id), key, weak: env.weak.clone(), account, @@ -919,6 +1040,10 @@ impl Segment { .map(|text| text.replace("resets in ", "\u{21bb} ")) } + fn has_chip(&self) -> bool { + self.resets_in.is_some() || self.credits > 0 + } + fn bar(&self, wide: bool, show_name: bool, width: f32, cx: &App) -> AnyElement { let theme = cx.theme(); let (foreground, muted, background) = @@ -956,35 +1081,29 @@ impl Segment { .bottom_0() .right_0() .w(relative(share)) - .bg(pattern_slash(self.color.opacity(0.2), 1., 5.)), + .child(hatch_pattern(self.color.opacity(0.2))), ); } if let Some(left) = self.time_left.filter(|_| width >= 6.) { - let at = (left as f32 / 100.).clamp(0., 1.); + let at = px(pace_mark_center(left, width)); track = track .child( - div() - .absolute() - .top_0() - .bottom_0() - .left(relative(at)) - .w_0() - .child( - div() - .absolute() - .top_0() - .bottom_0() - .left(px(-3.)) - .w(px(6.)) - .bg(background), - ), + div().absolute().top_0().bottom_0().left(at).w_0().child( + div() + .absolute() + .top_0() + .bottom_0() + .left(px(-3.)) + .w(px(6.)) + .bg(background), + ), ) .child( div() .absolute() .top(px(2.)) .bottom(px(2.)) - .left(relative(at)) + .left(at) .w_0() .child( div() @@ -999,42 +1118,47 @@ impl Segment { ); } if wide { - let chip = self.chip_text().map(|text| { + let chip = self.has_chip().then(|| { h_flex() .flex_shrink_0() .items_center() - .gap_1() + .gap(px(6.)) .rounded(px(4.)) .bg(background.opacity(0.85)) .px(px(6.)) .py(px(2.)) .text_size(px(11.)) - .child(text) + .line_height(px(16.)) + .children(self.chip_text()) .when(self.credits > 0, |chip| { - chip.child(div().text_color(muted).child("\u{b7}")) - .child(Icon::new(Icons::Ticket).size(px(12.))) - .child( - div() - .font_weight(FontWeight::SEMIBOLD) - .child(self.credits.to_string()), - ) + chip.children( + self.resets_in + .is_some() + .then(|| div().text_color(muted).child("\u{b7}")), + ) + .child( + h_flex() + .items_center() + .gap_0p5() + .font_weight(FontWeight::SEMIBOLD) + .child(Icon::new(Icons::Ticket).size(px(12.))) + .child(self.credits.to_string()), + ) }) }); - track = track.child( - h_flex() - .absolute() - .inset_0() - .items_center() - .justify_between() - .gap_2() - .px_2() - .text_xs() - .child( - h_flex() - .min_w_0() - .items_center() - .gap_1() - .when(show_name, |row| { + track = + track.child( + h_flex() + .absolute() + .inset_0() + .items_center() + .gap(px(6.)) + .px_2() + .text_size(px(12.)) + .line_height(px(16.)) + .child(h_flex().min_w_0().items_center().gap(px(6.)).when( + show_name, + |row| { row.child( div() .min_w_0() @@ -1047,10 +1171,11 @@ impl Segment { .font_weight(FontWeight::SEMIBOLD) .child(format!("{}%", self.remaining)), ) - }), - ) - .children(chip), - ); + }, + )) + .child(div().flex_1()) + .children(chip), + ); } else { track = track.child( div() @@ -1065,8 +1190,22 @@ impl Segment { .child(self.position.to_string()), ); } + if self.open { + track = track.child( + div() + .absolute() + .inset_0() + .rounded(px(6.)) + .border_1() + .border_color(theme.border), + ); + } let popover = self.popover(); HoverCard::new(("usage-limit-popover", self.id)) + .on_open_change(open_change(self.weak.clone(), self.id)) + .appearance(true) + .px_4() + .py_4() .anchor(Anchor::BottomCenter) .open_delay(Duration::from_millis(150)) .trigger_style(StyleRefinement::default().w_full()) @@ -1075,74 +1214,81 @@ impl Segment { .into_any_element() } - fn legend_row(&self, show_name: bool, cx: &App) -> AnyElement { + fn legend_row(&self, cx: &App) -> AnyElement { let theme = cx.theme(); let (muted, foreground) = (theme.muted_foreground, theme.foreground); - let right = self.chip_text().map(|text| { + let right = self.has_chip().then(|| { h_flex() .flex_shrink_0() .items_center() - .gap_1() + .gap(px(6.)) .text_size(px(11.)) + .line_height(px(16.)) .text_color(muted) - .child(text) + .children(self.chip_text()) .when(self.credits > 0, |row| { - row.child("\u{b7}") - .child(Icon::new(Icons::Ticket).size(px(12.))) + row.children(self.resets_in.is_some().then_some("\u{b7}")) .child( - div() + h_flex() + .items_center() + .gap_0p5() .font_weight(FontWeight::SEMIBOLD) .text_color(foreground) + .child(Icon::new(Icons::Ticket).size(px(12.))) .child(self.credits.to_string()), ) }) }); + let accent = foreground.opacity(0.06); + let open = self.open; let row = h_flex() .id(("usage-limit-legend", self.id)) .w_full() - .h(px(24.)) + .h(px(28.)) .items_center() - .justify_between() - .gap_2() - .px_1() + .gap_1() + .px(px(8.)) .rounded(px(6.)) - .text_xs() - .hover(|style| style.bg(foreground.opacity(0.06))) + .text_size(px(12.)) + .line_height(px(16.)) + .font_weight(FontWeight::MEDIUM) + .cursor_pointer() + .when(open, |row| row.bg(accent)) + .hover(move |style| style.bg(accent)) .child( - h_flex() - .min_w_0() + div() + .size(px(16.)) + .flex_shrink_0() + .flex() .items_center() - .gap_2() - .child( - div() - .size(px(16.)) - .flex_shrink_0() - .flex() - .items_center() - .justify_center() - .rounded(px(4.)) - .bg(self.color.opacity(0.35)) - .text_size(px(10.)) - .font_weight(FontWeight::SEMIBOLD) - .child(self.position.to_string()), - ) - .when(show_name, |row| { - row.child( - div() - .min_w_0() - .font_weight(FontWeight::MEDIUM) - .child(account_name(&self.account, &self.driver)), - ) - }) - .child( - div() - .font_weight(FontWeight::SEMIBOLD) - .child(format!("{}%", self.remaining)), - ), + .justify_center() + .rounded(px(4.)) + .bg(self.color.opacity(0.35)) + .text_size(px(10.)) + .line_height(px(14.)) + .font_weight(FontWeight::SEMIBOLD) + .text_color(foreground.opacity(0.8)) + .child(self.position.to_string()), ) + .child( + div() + .min_w_0() + .font_weight(FontWeight::MEDIUM) + .child(account_name(&self.account, &self.driver)), + ) + .child( + div() + .flex_shrink_0() + .font_weight(FontWeight::SEMIBOLD) + .child(format!("{}%", self.remaining)), + ) + .child(div().flex_1()) .children(right); let popover = self.popover(); HoverCard::new(("usage-limit-legend-popover", self.id)) + .on_open_change(open_change(self.weak.clone(), self.id)) + .px_4() + .py_4() .anchor(Anchor::BottomCenter) .open_delay(Duration::from_millis(150)) .trigger_style(StyleRefinement::default().w_full()) @@ -1259,7 +1405,8 @@ impl Popover { h_flex() .items_center() .gap_2() - .text_sm() + .text_size(px(14.)) + .line_height(px(20.)) .font_weight(FontWeight::MEDIUM) .child(account_avatar(account, &self.driver, cx)) .child(div().truncate().child(title)), @@ -1270,14 +1417,11 @@ impl Popover { .border_t_1() .border_color(border) .pt(px(10.)); - let mut rows = 0; if let Some(plan) = account.plan.as_ref().filter(|plan| !plan.is_empty()) { identity = identity.child(Self::row("Plan", plan.clone(), cx)); - rows += 1; } if !self.environment.is_empty() { identity = identity.child(Self::row("Signed in", self.environment.clone(), cx)); - rows += 1; } let mut figures = v_flex() .gap_1() @@ -1340,7 +1484,8 @@ impl Popover { .border_1() .border_color(theme.border) .text_color(foreground) - .text_xs() + .text_size(px(12.)) + .line_height(px(16.)) .font_weight(FontWeight::MEDIUM) .when(busy, |button| button.opacity(0.64)) .when(!busy, |button| { @@ -1356,13 +1501,13 @@ impl Popover { .child(if busy { "Using\u{2026}" } else { "Use reset" }), ) }); - let _ = rows; v_flex() .w(px(288.)) .gap(px(10.)) - .text_xs() + .text_size(px(12.)) + .line_height(px(16.)) .child(header) - .children((rows > 0).then_some(identity)) + .child(identity) .child(figures) .children(credit_section) .into_any_element() @@ -1422,7 +1567,8 @@ fn manage_usage_button(id: &'static str, url: &'static str, cx: &App) -> gpui::S .gap_1() .items_center() .rounded(px(6.)) - .text_xs() + .text_size(px(12.)) + .line_height(px(16.)) .font_weight(FontWeight::MEDIUM) .text_color(muted) .cursor_pointer() @@ -1445,7 +1591,8 @@ fn link_card(index: usize, link: &Link, cx: &App) -> AnyElement { .gap_1() .items_center() .rounded(px(6.)) - .text_xs() + .text_size(px(12.)) + .line_height(px(16.)) .font_weight(FontWeight::MEDIUM) .text_color(muted) .cursor_pointer() @@ -1490,20 +1637,23 @@ fn link_card(index: usize, link: &Link, cx: &App) -> AnyElement { .gap_1() .child( div() - .text_sm() + .text_size(px(14.)) + .line_height(px(20.)) .font_weight(FontWeight::MEDIUM) .child(link.label.clone()), ) .child( div() - .text_xs() + .text_size(px(12.)) + .line_height(px(16.)) .text_color(muted) .child(link.accounts.join(", ")), ) .when(chatgpt, |column| { column.child( div() - .text_xs() + .text_size(px(12.)) + .line_height(px(16.)) .text_color(muted) .child("View usage in ChatGPT with your connected account."), ) @@ -1512,7 +1662,8 @@ fn link_card(index: usize, link: &Link, cx: &App) -> AnyElement { column.children(link.message.as_ref().map(|message| { div() .max_w(px(576.)) - .text_xs() + .text_size(px(12.)) + .line_height(px(16.)) .text_color(muted) .child(message.clone()) })) @@ -1526,27 +1677,60 @@ fn link_card(index: usize, link: &Link, cx: &App) -> AnyElement { /// `LimitNotices`: a warning alert with one title per notice. fn notice_alert(notices: &[String], cx: &App) -> AnyElement { let theme = cx.theme(); + let dark = theme.is_dark(); + let text_color: Hsla = rgb(if dark { 0xfbbf24 } else { 0xb45309 }).into(); h_flex() .items_start() - .gap_3() - .rounded(px(8.)) + .gap_2() + .rounded(px(12.)) .border_1() .border_color(theme.warning.opacity(0.32)) - .bg(theme.warning.opacity(0.04)) - .px_4() + .bg(theme.warning.opacity(if dark { 0.16 } else { 0.08 })) + .px(px(14.)) .py_3() + .text_size(px(14.)) + .line_height(px(20.)) + .text_color(text_color) .child( - Icon::new(Icons::TriangleAlert) - .size(px(16.)) - .text_color(theme.warning), + div() + .h(px(20.)) + .w(px(16.)) + .flex_shrink_0() + .flex() + .items_center() + .child( + Icon::new(Icons::TriangleAlert) + .size(px(16.)) + .text_color(theme.warning), + ), ) .child( v_flex() .min_w_0() - .gap_1() - .text_sm() + .flex_1() + .gap_0p5() .font_weight(FontWeight::MEDIUM) .children(notices.iter().map(|notice| div().child(notice.clone()))), ) .into_any_element() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn segment_width_subtracts_gaps() { + assert_eq!(segment_width(400., 1), 400.); + assert_eq!(segment_width(404., 2), 200.); + assert_eq!(segment_width(408., 3), 400. / 3.); + } + + #[test] + fn pace_mark_clamps_to_three_px_from_edges() { + assert_eq!(pace_mark_center(0, 200.), 3.); + assert_eq!(pace_mark_center(50, 200.), 100.); + assert_eq!(pace_mark_center(100, 200.), 197.); + assert_eq!(pace_mark_center(1, 200.), 3.); + } +} diff --git a/apps/desktop-gpui/src/ui/usage_dialog/pickers.rs b/apps/desktop-gpui/src/ui/usage_dialog/pickers.rs new file mode 100644 index 000000000000..8d4f8c12184c --- /dev/null +++ b/apps/desktop-gpui/src/ui/usage_dialog/pickers.rs @@ -0,0 +1,441 @@ +//! The breadcrumb pickers of `UsagePage.tsx` (`UsageEnvironmentFilter`, `UsageProviderFilter`) +//! and the `Menu` rows they share with the price dialog's "Apply to" picker. + +use super::*; +use crate::usage_data::Hidden; +use gpui::Anchor; +use gpui_component::popover::Popover; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Menu { + Environments, + Providers, + Apply, +} + +/// `InlineButton` as a `MenuTrigger`: underlines on hover, chevron fades in. +#[derive(IntoElement)] +pub(super) struct Crumb { + id: &'static str, + label: String, + alert: bool, + open: bool, +} + +impl Selectable for Crumb { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } + + fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + fn is_open(&self) -> bool { + self.open + } +} + +impl RenderOnce for Crumb { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let (foreground, muted, warning) = + (theme.foreground, theme.muted_foreground, theme.warning); + let open = self.open; + let icon = if self.alert { + Icon::new(Icons::CircleAlert) + .size(px(14.)) + .text_color(warning) + .into_any_element() + } else { + Icon::new(Icons::ChevronDown) + .size(px(14.)) + .into_any_element() + }; + let chevron = div() + .size(px(14.)) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .text_color(muted) + .when(!self.alert, |chevron| { + chevron + .opacity(if open { 1. } else { 0.6 }) + .group_hover(self.id, |style| style.opacity(1.)) + }) + .child(icon); + h_flex() + .id(self.id) + .group(self.id) + .min_w_0() + .max_w_full() + .gap(px(2.)) + .items_center() + .text_color(foreground) + .font_weight(FontWeight::MEDIUM) + .cursor_pointer() + .hover(|style| style.underline()) + .child(div().min_w_0().truncate().child(self.label)) + .child(chevron) + } +} + +/// `MenuCheckboxItem`: a 16px check column, then the content. +pub(super) fn check_row( + id: impl Into, + checked: bool, + content: impl IntoElement, + cx: &App, +) -> gpui::Stateful { + let theme = cx.theme(); + let hover = theme.accent; + h_flex() + .id(id) + .min_h(px(28.)) + .items_center() + .gap_2() + .pl_2() + .pr_4() + .rounded(px(4.)) + .text_sm() + .cursor_pointer() + .hover(move |style| style.bg(hover)) + .child(div().size(px(16.)).flex_shrink_0().when(checked, |slot| { + slot.child(Icon::new(Icons::Check).size(px(16.))) + })) + .child(div().min_w_0().flex_1().child(content)) +} + +/// `MenuSeparator`. +pub(super) fn separator(cx: &App) -> gpui::Div { + div().mx_2().my_1().h(px(1.)).bg(cx.theme().border) +} + +/// `MenuPopup`: `p-1`, at least 10rem wide. +pub(super) fn menu_popup() -> gpui::Div { + v_flex().min_w(px(160.)).p_1() +} + +pub(super) fn menu_note(text: &'static str, cx: &App) -> gpui::Div { + div() + .px_2() + .py_2() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(text) +} + +/// One environment row's right-aligned status text. +pub(super) fn status_text(text: String, destructive: bool, cx: &App) -> gpui::Div { + let theme = cx.theme(); + div() + .flex_shrink_0() + .text_xs() + .text_color(if destructive { + theme.danger + } else { + theme.muted_foreground + }) + .child(text) +} + +/// `updatingProvidersLabel`. +fn updating_label(providers: &[Provider]) -> String { + if providers.len() == 1 { + format!("Updating {}\u{2026}", providers[0].label()) + } else { + format!("Updating {} providers\u{2026}", providers.len()) + } +} + +/// What the environment menu shows for the one connected environment. +#[derive(Clone)] +struct EnvironmentMenu { + label: String, + status: String, + error: bool, + present: bool, + selected: bool, + show_status: bool, + partial: bool, +} + +impl UsageDialog { + /// The status text in `UsageEnvironmentFilter`, in the web's precedence. + fn environment_status(&self) -> (String, bool) { + let mismatch = self + .shown + .as_ref() + .is_some_and(|shown| shown.merged.mismatch.is_some()); + if self.failed { + return ("Unavailable".to_owned(), true); + } + let text = if mismatch { + "Update required".to_owned() + } else if self.access != Access::Ready { + "Connecting\u{2026}".to_owned() + } else if let Some(shown) = &self.shown { + if self.manual { + "Refreshing\u{2026}".to_owned() + } else if self.stage == Stage::Idle { + "Ready".to_owned() + } else { + let mut providers: Vec = Vec::new(); + for source in shown.summary.sources.iter().filter(|s| s.refreshing) { + if !providers.contains(&source.provider) { + providers.push(source.provider); + } + } + if providers.is_empty() { + "Refreshing\u{2026}".to_owned() + } else { + updating_label(&providers) + } + } + } else { + "Scanning\u{2026}".to_owned() + }; + (text, false) + } + + /// `hasIssue`: the selected environment failed or runs another contract version. + fn environment_issue(&self) -> bool { + self.env_selected + && (self.failed + || self + .shown + .as_ref() + .is_some_and(|shown| shown.merged.mismatch.is_some())) + } + + pub(super) fn environment_label(&self) -> String { + if self.env_selected { + "All environments".to_owned() + } else { + "0 environments".to_owned() + } + } + + pub(super) fn set_menu(&mut self, menu: Option, cx: &mut Context) { + if self.menu != menu { + self.menu = menu; + cx.notify(); + } + } + + /// `onSelectionChange` for the sole environment: every row toggles between all and none. + pub(super) fn toggle_environment(&mut self, cx: &mut Context) { + self.env_selected = !self.env_selected; + self.hover = None; + if self.env_selected { + self.ensure_loaded(); + } + cx.notify(); + } + + pub(super) fn set_hidden(&mut self, hidden: Hidden, cx: &mut Context) { + if self.prefs.hidden == hidden { + return; + } + self.prefs.hidden = hidden; + self.prefs.save(); + self.hover = None; + if let Some(shown) = &mut self.shown { + shown.merged = data::merge(&shown.summary.without(hidden)); + } + cx.notify(); + } + + pub(super) fn environment_picker(&self, cx: &mut Context) -> AnyElement { + let view = cx.entity(); + let limits = self.prefs.metric == Metric::Limits; + let (status, error) = self.environment_status(); + let menu = EnvironmentMenu { + label: self.limits.environment.clone(), + status, + error, + present: self.access != Access::Unavailable, + selected: self.env_selected, + show_status: !limits, + partial: !limits && self.dim().partial(), + }; + let alert = !limits && self.environment_issue(); + Popover::new("usage-environments") + .anchor(Anchor::TopLeft) + .open(self.menu == Some(Menu::Environments)) + .p_0() + .on_open_change({ + let view = view.clone(); + move |open, _, cx| { + let menu = open.then_some(Menu::Environments); + view.update(cx, |this, cx| this.set_menu(menu, cx)); + } + }) + .trigger(Crumb { + id: "usage-crumb-environments", + label: self.environment_label(), + alert, + open: false, + }) + .content(move |_, _, cx| { + let theme = cx.theme(); + let muted = theme.muted_foreground; + let toggle = { + let view = view.clone(); + move |_: &ClickEvent, _: &mut Window, cx: &mut App| { + view.update(cx, |this, cx| this.toggle_environment(cx)); + } + }; + let environment = menu.present.then(|| { + let label = if menu.label.is_empty() { + "Environment".to_owned() + } else { + menu.label.clone() + }; + check_row( + "usage-environment-row", + menu.selected, + h_flex() + .min_w_0() + .items_center() + .gap_3() + .child(div().min_w_0().flex_1().truncate().child(label)) + .when(menu.show_status, |row| { + row.child(status_text(menu.status.clone(), menu.error, cx)) + }), + cx, + ) + .on_click(toggle.clone()) + }); + let open_prices = { + let view = view.clone(); + move |_: &ClickEvent, window: &mut Window, cx: &mut App| { + view.update(cx, |this, cx| this.open_prices(window, cx)); + } + }; + menu_popup() + .child( + check_row( + "usage-environments-all", + menu.selected, + "All environments", + cx, + ) + .on_click(toggle.clone()), + ) + .child(separator(cx)) + .children(environment) + .when(!menu.present, |list| { + list.child(menu_note("No environments connected.", cx)) + }) + .when(menu.show_status && menu.partial, |list| { + list.child(menu_note( + "Totals are partial while selected environments scan.", + cx, + )) + }) + .child(separator(cx)) + .child( + h_flex() + .id("usage-model-prices") + .min_h(px(28.)) + .items_center() + .gap_2() + .px_2() + .rounded(px(4.)) + .text_sm() + .cursor_pointer() + .hover({ + let accent = theme.accent; + move |style| style.bg(accent) + }) + .on_click(open_prices) + .child( + Icon::new(Icons::SlidersHorizontal) + .size(px(16.)) + .text_color(muted), + ) + .child("Model prices"), + ) + }) + .into_any_element() + } + + pub(super) fn provider_picker(&self, cx: &mut Context) -> AnyElement { + let view = cx.entity(); + let hidden = self.prefs.hidden; + Popover::new("usage-providers") + .anchor(Anchor::TopLeft) + .open(self.menu == Some(Menu::Providers)) + .p_0() + .on_open_change({ + let view = view.clone(); + move |open, _, cx| { + let menu = open.then_some(Menu::Providers); + view.update(cx, |this, cx| this.set_menu(menu, cx)); + } + }) + .trigger(Crumb { + id: "usage-crumb-providers", + label: hidden.label(), + alert: false, + open: false, + }) + .content(move |_, _, cx| { + let all = { + let view = view.clone(); + // `onChange(checked ? [] : PROVIDER_ORDER)`, where `checked` is the new state. + move |_: &ClickEvent, _: &mut Window, cx: &mut App| { + view.update(cx, |this, cx| { + let next = if this.prefs.hidden.is_empty() { + Hidden::all() + } else { + Hidden::default() + }; + this.set_hidden(next, cx); + }); + } + }; + let rows = Provider::ORDER.into_iter().map(|provider| { + let view = view.clone(); + let visible = !hidden.contains(provider); + check_row( + ("usage-provider-row", provider.slot()), + visible, + h_flex() + .min_w_0() + .items_center() + .gap_2() + .child(mark(provider, 14., cx)) + .child(div().truncate().child(provider.label())), + cx, + ) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| { + let next = this.prefs.hidden.with(provider, !visible); + this.set_hidden(next, cx); + }); + }) + }); + menu_popup() + .child( + check_row( + "usage-providers-all", + hidden.is_empty(), + "All providers", + cx, + ) + .on_click(all), + ) + .child(separator(cx)) + .children(rows) + }) + .into_any_element() + } +} diff --git a/apps/desktop-gpui/src/ui/usage_dialog/prices.rs b/apps/desktop-gpui/src/ui/usage_dialog/prices.rs new file mode 100644 index 000000000000..d744d52307df --- /dev/null +++ b/apps/desktop-gpui/src/ui/usage_dialog/prices.rs @@ -0,0 +1,1374 @@ +//! The "Custom model prices" dialog of `UsagePriceOverrides.tsx`. + +use super::pickers::{Menu, check_row, menu_popup, separator, status_text}; +use super::*; +use crate::usage_prices::{ + self as price, Change, Draft, Field, Snapshot, Target as PriceTarget, Write, +}; +use gpui::Anchor; +use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::popover::Popover; +use std::collections::{BTreeSet, HashMap, HashSet}; + +/// `Dialog`'s `max-w-4xl`. +const DIALOG_MAX: f32 = 896.; +/// The table's `min-w-180`. +const TABLE_MIN: f32 = 720.; +/// The last column's `w-10`. +const ACTION_WIDTH: f32 = 40.; +const SAVE_FAILED: &str = "Could not save. Try again."; + +/// The settings the dialog edits. `local` follows `seen` whenever the catalog reports new +/// settings, and takes a saved change at once so the rows do not wait for a settings read. +#[derive(Default)] +pub(super) struct PriceStore { + seen: Snapshot, + local: Snapshot, +} + +impl PriceStore { + pub(super) fn refresh(&mut self, seen: Snapshot) { + if seen != self.seen { + self.local = seen.clone(); + self.seen = seen; + } + } + + fn apply(&mut self, changes: &[Change]) { + let mut target = PriceTarget { + label: String::new(), + prices: self.local.0.take(), + aliases: self.local.1.take(), + unavailable: None, + }; + price::apply_changes(&mut target, changes); + self.local = (target.prices, target.aliases); + } +} + +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Kind { + Model, + Alias, + Rate(Field), +} + +type Key = (String, Kind); + +struct Slot { + input: Entity, + placeholder: &'static str, +} + +#[derive(Clone, PartialEq)] +enum Outcome { + Saving, + Saved, + Failed(String), +} + +/// `SaveAttempt`: the drafts a save sent and how the environment answered. +struct Attempt { + drafts: Vec, + outcome: Outcome, +} + +/// The dialog's state (`UsagePriceOverrides`' `useState` hooks). +pub(super) struct Prices { + /// `selectedIds === null`: every environment. Otherwise none, with one environment. + all: bool, + drafts: Vec, + attempt: Option, + pending: bool, + next_row: u32, + focus_row: Option, + inputs: HashMap, +} + +impl Prices { + fn new(all: bool) -> Self { + Self { + all, + drafts: Vec::new(), + attempt: None, + pending: false, + next_row: 0, + focus_row: None, + inputs: HashMap::new(), + } + } + + fn failed(&self) -> bool { + self.attempt + .as_ref() + .is_some_and(|attempt| matches!(attempt.outcome, Outcome::Failed(_))) + } + + fn locked(&self) -> bool { + self.pending || self.failed() + } + + fn staged(&self) -> Vec { + self.drafts + .iter() + .filter(|draft| !price::is_empty_draft(draft)) + .cloned() + .collect() + } + + /// `updateDraft`. + fn update_draft(&mut self, draft: Draft) { + match self.drafts.iter_mut().find(|entry| entry.id == draft.id) { + Some(entry) => *entry = draft, + None => self.drafts.push(draft), + } + self.attempt = None; + } + + /// `updateOrDropDraft`: a saved row left with no edits is no draft at all. + fn update_or_drop(&mut self, draft: Draft) { + if !draft.is_new && draft.values.is_empty() && draft.alias.is_none() { + self.drafts.retain(|entry| entry.id != draft.id); + } else { + self.update_draft(draft); + } + } + + /// `editCell`. + fn edit_cell(&mut self, targets: &[PriceTarget], row: &Draft, field: Field, value: String) { + let mut row = row.clone(); + row.values.set(field, value.clone()); + let original = price::price_cell(targets, &row.model, field); + if matches_saved(&row, &original, &value) { + row.values.remove(field); + } + self.update_or_drop(row); + } + + /// `editAlias`. + fn edit_alias(&mut self, targets: &[PriceTarget], row: &Draft, value: String) { + let mut next = row.clone(); + next.alias = None; + let original = price::alias_cell(targets, &row.model); + if !matches_saved(row, &original, &value) { + next.alias = Some(value); + } + self.update_or_drop(next); + } +} + +/// `matchesSaved`: a mixed or unavailable cell never matches. +fn matches_saved(row: &Draft, original: &price::Cell, value: &str) -> bool { + !row.is_new && original.is_comparable() && value == original.value +} + +/// The models that have a custom price or mapping on any selected environment, sorted. +fn custom_models(targets: &[PriceTarget]) -> Vec { + let mut models = BTreeSet::new(); + for target in targets { + models.extend( + target + .prices + .iter() + .flat_map(|prices| prices.keys().cloned()), + ); + models.extend( + target + .aliases + .iter() + .flat_map(|aliases| aliases.keys().cloned()), + ); + } + models.into_iter().collect() +} + +/// `rows`: the saved models, with their drafts applied, then the new rows. Once a save was +/// attempted a saved model that a new row now names is shown by that row alone. +fn rows(targets: &[PriceTarget], drafts: &[Draft], attempted: bool) -> Vec { + let new_models: HashSet<&str> = drafts + .iter() + .filter(|draft| draft.is_new) + .map(|draft| draft.model.trim()) + .collect(); + let mut rows: Vec = custom_models(targets) + .into_iter() + .filter(|model| !attempted || !new_models.contains(model.as_str())) + .map(|model| { + let id = format!("model:{model}"); + drafts + .iter() + .find(|draft| draft.id == id) + .cloned() + .unwrap_or_else(|| Draft::new(id, &model, false)) + }) + .collect(); + rows.extend(drafts.iter().filter(|draft| draft.is_new).cloned()); + rows +} + +/// `errors`: the table's errors plus the ones for new rows. +fn errors(targets: &[PriceTarget], drafts: &[Draft], attempted: bool) -> HashMap { + let staged: Vec = drafts + .iter() + .filter(|draft| !price::is_empty_draft(draft)) + .cloned() + .collect(); + let mut errors = price::table_errors(targets, &staged); + let custom = custom_models(targets); + for draft in staged.iter().filter(|draft| draft.is_new) { + let model = draft.model.trim(); + if model.is_empty() { + errors.insert(draft.id.clone(), "Enter a model ID.".to_owned()); + } else if !attempted + && (custom.iter().any(|saved| saved == model) + || drafts + .iter() + .any(|other| other.id != draft.id && other.model.trim() == model)) + { + errors.insert( + draft.id.clone(), + "This model already has a row. Edit its prices there.".to_owned(), + ); + } + } + errors +} + +/// Button variants the dialog uses (`ui/button.tsx`). +#[derive(Clone, Copy, PartialEq)] +enum Variant { + Primary, + Ghost, +} + +/// A `size="default"` button: `h-8`, `text-sm`, `font-medium`. +fn button( + id: &'static str, + label: &'static str, + variant: Variant, + enabled: bool, + cx: &App, +) -> gpui::Stateful { + let theme = cx.theme(); + let (primary, on_primary, accent, foreground) = ( + theme.primary, + theme.primary_foreground, + theme.accent, + theme.foreground, + ); + div() + .id(id) + .h(px(32.)) + .px(px(12.)) + .flex() + .items_center() + .justify_center() + .rounded(px(8.)) + .border_1() + .text_size(px(14.)) + .line_height(px(20.)) + .font_weight(FontWeight::MEDIUM) + .map(|button| match variant { + Variant::Primary => button + .border_color(primary) + .bg(primary) + .text_color(on_primary) + .when(enabled, |button| { + button.hover(move |style| style.bg(primary.opacity(0.9))) + }), + Variant::Ghost => button + .border_color(gpui::transparent_black()) + .text_color(foreground) + .when(enabled, |button| { + button.hover(move |style| style.bg(accent)) + }), + }) + .when(enabled, |button| button.cursor_pointer()) + .when(!enabled, |button| button.opacity(0.64)) + .child(label) +} + +/// A `size="icon-xs"` ghost button: `size-6`, 14px icon. +fn icon_button( + id: impl Into, + icon: impl IntoElement, + label: &'static str, + enabled: bool, + cx: &App, +) -> gpui::Stateful { + let theme = cx.theme(); + let (accent, foreground) = (theme.accent, theme.foreground); + div() + .id(id) + .flex_shrink_0() + .size(px(24.)) + .flex() + .items_center() + .justify_center() + .rounded(px(8.)) + .text_color(foreground) + .when(enabled, |button| { + button + .cursor_pointer() + .hover(move |style| style.bg(accent)) + .tooltip(tooltip(label)) + }) + .when(!enabled, |button| button.opacity(0.64)) + .child(icon) +} + +/// `Button variant="outline" size="sm"` as the "Apply to" menu trigger. +#[derive(IntoElement)] +struct ApplyTrigger { + label: String, + enabled: bool, + open: bool, +} + +impl Selectable for ApplyTrigger { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } + + fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + fn is_open(&self) -> bool { + self.open + } +} + +impl RenderOnce for ApplyTrigger { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let dark = theme.is_dark(); + let (border, foreground, hover) = ( + theme.input, + theme.foreground, + if dark { + theme.input.opacity(0.64) + } else { + theme.accent.opacity(0.5) + }, + ); + let background = if dark { + theme.input.opacity(0.32) + } else { + theme.popover + }; + let enabled = self.enabled; + h_flex() + .id("usage-prices-apply") + .min_w_0() + .h(px(28.)) + .px(px(10.)) + .gap(px(6.)) + .items_center() + .rounded(px(8.)) + .border_1() + .border_color(border) + .bg(if self.open { hover } else { background }) + .text_color(foreground) + .text_size(px(14.)) + .line_height(px(20.)) + .font_weight(FontWeight::MEDIUM) + .when(enabled, |button| { + button.cursor_pointer().hover(move |style| style.bg(hover)) + }) + .when(!enabled, |button| button.opacity(0.64)) + .child(div().min_w_0().truncate().child(self.label)) + .child(Icon::new(Icons::ChevronDown).size(px(14.)).flex_shrink_0()) + } +} + +fn cell(width: f32) -> gpui::Div { + div().w(px(width)).flex_shrink_0().min_w_0().p_2() +} + +impl UsageDialog { + /// `priceTargetsAtom` for the one environment this app is paired with. + pub(super) fn price_target(&self) -> PriceTarget { + let label = if self.limits.environment.is_empty() { + "Environment".to_owned() + } else { + self.limits.environment.clone() + }; + let (prices, aliases) = self.price_store.local.clone(); + let unavailable = if self.access != Access::Ready { + Some("Offline".to_owned()) + } else if prices.is_none() { + Some("Prices not loaded".to_owned()) + } else { + None + }; + PriceTarget { + label, + prices, + aliases, + unavailable, + } + } + + fn price_selected(&self) -> Vec { + match &self.prices { + Some(prices) if prices.all && self.access != Access::Unavailable => { + vec![self.price_target()] + } + _ => Vec::new(), + } + } + + pub(super) fn open_prices(&mut self, _: &mut Window, cx: &mut Context) { + self.menu = None; + self.prices = Some(Prices::new(self.env_selected)); + cx.notify(); + } + + /// Closing waits for a save in flight (`onOpenChange` guard). + pub(super) fn close_prices(&mut self, cx: &mut Context) { + if self.prices.as_ref().is_some_and(|prices| !prices.pending) { + self.prices = None; + self.menu = None; + cx.notify(); + } + } + + fn set_apply(&mut self, all: bool, cx: &mut Context) { + if let Some(prices) = &mut self.prices { + prices.all = all; + prices.attempt = None; + cx.notify(); + } + } + + fn discard_prices(&mut self, cx: &mut Context) { + if let Some(prices) = &mut self.prices { + prices.drafts.clear(); + prices.attempt = None; + cx.notify(); + } + } + + fn add_price_row(&mut self, cx: &mut Context) { + if let Some(prices) = &mut self.prices { + let id = format!("new:{}", prices.next_row); + prices.next_row += 1; + prices.focus_row = Some(id.clone()); + prices.update_draft(Draft::new(id, "", true)); + cx.notify(); + } + } + + fn price_row_action(&mut self, id: &str, cx: &mut Context) { + let targets = self.price_selected(); + let Some(prices) = &mut self.prices else { + return; + }; + let Some(row) = rows(&targets, &prices.drafts, prices.attempt.is_some()) + .into_iter() + .find(|row| row.id == id) + else { + return; + }; + if row.is_new { + prices.drafts.retain(|entry| entry.id != row.id); + } else if row.removed { + let mut row = row; + row.removed = false; + prices.update_or_drop(row); + } else { + let mut row = row; + row.removed = true; + prices.update_draft(row); + } + cx.notify(); + } + + /// An edit in one of the table's inputs. + fn price_input_event( + &mut self, + key: &Key, + input: &Entity, + event: &InputEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + InputEvent::Focus | InputEvent::Blur => { + cx.notify(); + return; + } + InputEvent::Change => {} + _ => return, + } + let text = input.read(cx).value().to_string(); + let targets = self.price_selected(); + let Some(prices) = &mut self.prices else { + return; + }; + if prices.locked() { + return; + } + let Some(row) = rows(&targets, &prices.drafts, prices.attempt.is_some()) + .into_iter() + .find(|row| row.id == key.0) + else { + return; + }; + match key.1 { + Kind::Model => { + let mut row = row; + row.model = text; + prices.update_draft(row); + } + Kind::Alias => prices.edit_alias(&targets, &row, text), + Kind::Rate(field) => { + let current = row.values.get(field).map(str::to_owned).unwrap_or_else(|| { + if row.is_new { + String::new() + } else { + price::price_cell(&targets, &row.model, field).value + } + }); + if price::is_price_edit(¤t, &text) { + prices.edit_cell(&targets, &row, field, text); + } else { + input.update(cx, |state, cx| state.set_value(current, window, cx)); + } + } + } + cx.notify(); + } + + /// `save`: one environment, so one write. + fn save_prices(&mut self, retry: bool, cx: &mut Context) { + let selected = self.price_selected(); + let mut target = self.price_target(); + let Some(prices) = &mut self.prices else { + return; + }; + if prices.pending { + return; + } + let edits = match (&prices.attempt, retry) { + (Some(attempt), true) => attempt.drafts.clone(), + _ => prices.staged(), + }; + let destination = if retry { + prices.failed() + } else { + !selected.is_empty() + }; + if !destination || edits.is_empty() { + return; + } + let (changes, errors) = price::table_changes(&target, &edits); + if target.unavailable.is_none() { + target.unavailable = edits + .iter() + .find_map(|draft| errors.get(&draft.id).cloned()); + } + let plan = price::plan_write(&target, &changes); + prices.pending = true; + prices.attempt = Some(Attempt { + drafts: edits, + outcome: Outcome::Saving, + }); + match plan { + Write::Failed(reason) => self.prices_finished(Outcome::Failed(reason), &[]), + Write::Saved => self.prices_finished(Outcome::Saved, &[]), + Write::Patch(payload) => { + self.call(Call::UpdateSettings, payload, Pending::Prices { changes }) + } + } + cx.notify(); + } + + fn prices_finished(&mut self, outcome: Outcome, changes: &[Change]) { + let saved = outcome == Outcome::Saved; + if let Some(prices) = &mut self.prices { + prices.pending = false; + if let Some(attempt) = &mut prices.attempt { + attempt.outcome = outcome; + } + if saved { + prices.drafts.clear(); + } + } + if saved && !changes.is_empty() { + self.price_store.apply(changes); + // The web's usage summary rebuilds when the settings change. + if self.can_read() { + self.start_query(); + } + } + } + + pub(super) fn prices_settled(&mut self, changes: Vec, event: CallEvent) { + let outcome = match event { + CallEvent::Done(_) => Outcome::Saved, + _ => Outcome::Failed(SAVE_FAILED.to_owned()), + }; + self.prices_finished(outcome, &changes); + } + + /// `input_for`: the state behind one input, set to the text and placeholder it shows. + fn price_input( + &mut self, + prices: &mut Prices, + key: Key, + text: &str, + placeholder: &'static str, + window: &mut Window, + cx: &mut Context, + ) -> Entity { + let slot = prices.inputs.entry(key.clone()).or_insert_with(|| { + let input = cx.new(|cx| InputState::new(window, cx).placeholder(placeholder)); + let key = key.clone(); + cx.subscribe_in( + &input, + window, + move |this, input, event: &InputEvent, window, cx| { + this.price_input_event(&key, input, event, window, cx); + }, + ) + .detach(); + Slot { input, placeholder } + }); + if slot.placeholder != placeholder { + slot.placeholder = placeholder; + slot.input.update(cx, |state, cx| { + state.set_placeholder(placeholder, window, cx) + }); + } + if slot.input.read(cx).value().as_ref() != text { + let text = text.to_owned(); + slot.input + .update(cx, |state, cx| state.set_value(text, window, cx)); + } + slot.input.clone() + } + + /// `Input size="compact"`. + #[allow(clippy::too_many_arguments)] + fn field( + &self, + input: &Entity, + invalid: bool, + disabled: bool, + mono: bool, + window: &Window, + cx: &App, + ) -> gpui::Div { + let theme = cx.theme(); + let focused = input.read(cx).focus_handle(cx).contains_focused(window, cx); + let border = if invalid { + theme.danger.opacity(0.36) + } else if focused { + theme.ring + } else { + theme.input + }; + let background = if theme.is_dark() { + theme.input.opacity(0.32) + } else { + theme.background + }; + div() + .w_full() + .h(px(30.)) + .px(px(9.)) + .rounded(px(6.)) + .border_1() + .border_color(border) + .bg(background) + .text_size(px(12.)) + .when(mono, |field| { + field.font_family(theme.mono_font_family.clone()) + }) + .when(disabled, |field| field.opacity(0.64)) + .child( + Input::new(input) + .appearance(false) + .bordered(false) + .disabled(disabled) + .h(px(28.)) + .px_0(), + ) + } + + pub(super) fn prices_dialog( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let mut prices = self.prices.take()?; + let element = self.prices_overlay(&mut prices, window, cx); + self.prices = Some(prices); + Some(element) + } + + fn prices_overlay( + &mut self, + prices: &mut Prices, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let viewport = window.viewport_size(); + let (vw, vh) = (f32::from(viewport.width), f32::from(viewport.height)); + let dialog_width = (vw - 32.).min(DIALOG_MAX); + let table_width = (dialog_width - 48. - 2.).max(TABLE_MIN); + + let present = self.access != Access::Unavailable; + let environment = self.price_target(); + let selected = self.price_selected(); + let attempted = prices.attempt.is_some(); + let all_rows = rows(&selected, &prices.drafts, attempted); + let row_errors = errors(&selected, &prices.drafts, attempted); + let has_changes = !prices.staged().is_empty(); + let failed = prices.failed(); + let locked = prices.locked(); + let pending = prices.pending; + let destination = if selected.len() == 1 { + selected[0].label.clone() + } else { + format!("{} environments", selected.len()) + }; + let selection_label = if prices.all { + "All environments".to_owned() + } else { + destination.clone() + }; + + let theme = cx.theme(); + let (border, background, muted, foreground, danger) = ( + theme.border, + theme.background, + theme.muted_foreground, + theme.foreground, + theme.danger, + ); + let hover_row = theme.muted.opacity(0.5); + let backdrop = background.opacity(if theme.is_dark() { 0.64 } else { 0.6 }); + + // Column widths: 22%, 18%, four equal, then the action column. + let model_width = table_width * 0.22; + let alias_width = table_width * 0.18; + let rate_width = (table_width - model_width - alias_width - ACTION_WIDTH) / 4.; + + let apply = { + let view = cx.entity(); + let all = prices.all; + let unavailable = environment.unavailable.clone(); + let label = environment.label.clone(); + let enabled = !(pending || has_changes); + Popover::new("usage-prices-apply-menu") + .anchor(Anchor::TopLeft) + .open(self.menu == Some(Menu::Apply) && enabled) + .p_0() + .on_open_change({ + let view = view.clone(); + move |open, _, cx| { + let menu = (*open && enabled).then_some(Menu::Apply); + view.update(cx, |this, cx| this.set_menu(menu, cx)); + } + }) + .trigger(ApplyTrigger { + label: selection_label, + enabled, + open: false, + }) + .content(move |_, _, cx| { + let toggle = { + let view = view.clone(); + move |_: &ClickEvent, _: &mut Window, cx: &mut App| { + view.update(cx, |this, cx| { + let next = !this.prices.as_ref().is_some_and(|p| p.all); + this.set_apply(next, cx); + }); + } + }; + let environment = present.then(|| { + check_row( + "usage-prices-environment", + all, + h_flex() + .min_w_0() + .items_center() + .gap_3() + .child(div().min_w_0().flex_1().truncate().child(label.clone())) + .children( + unavailable.clone().map(|text| status_text(text, false, cx)), + ), + cx, + ) + .on_click(toggle.clone()) + }); + menu_popup() + .child( + check_row("usage-prices-all", all, "All environments", cx) + .on_click(toggle.clone()), + ) + .child(separator(cx)) + .children(environment) + }) + }; + + let top = h_flex() + .flex_wrap() + .items_center() + .justify_between() + .gap_2() + .child( + h_flex() + .min_w_0() + .items_center() + .gap_2() + .child( + div() + .flex_shrink_0() + .text_size(px(14.)) + .line_height(px(16.)) + .font_weight(FontWeight::MEDIUM) + .child("Apply to"), + ) + .child(apply), + ) + .child( + div() + .text_xs() + .text_color(muted) + .child("USD / million tokens"), + ); + + let table = if selected.is_empty() { + div() + .text_size(px(14.)) + .line_height(px(20.)) + .text_color(muted) + .child(if present { + "Select an environment to see and change its model prices." + } else { + "Connect an environment to set model prices." + }) + .into_any_element() + } else { + let head = |label: &'static str, width: f32| { + div().w(px(width)).flex_shrink_0().px_2().child(label) + }; + let header = h_flex() + .h(px(40.)) + .items_center() + .border_b_1() + .border_color(border) + .font_weight(FontWeight::MEDIUM) + .child(head("Model ID", model_width)) + .child(head("Map to", alias_width)) + .children( + Field::ALL + .into_iter() + .map(|field| head(field.label(), rate_width)), + ) + .child( + cell(ACTION_WIDTH).child( + icon_button( + "usage-prices-add", + Icon::new(Icons::Plus).size(px(14.)), + "Add model price", + !locked, + cx, + ) + .when(!locked, |button| { + button.on_click(cx.listener(|this, _, _, cx| this.add_price_row(cx))) + }), + ), + ); + + let mut body = v_flex(); + if all_rows.is_empty() { + body = body.child(div().w_full().p_2().text_center().child( + div().py_6().text_color(muted).child( + if selected.iter().any(|target| target.prices.is_none()) { + "Some environment prices are unavailable." + } else { + "No custom prices or mappings. Add a row to set one." + }, + ), + )); + } + let live: HashSet = all_rows.iter().map(|row| row.id.clone()).collect(); + prices.inputs.retain(|(id, _), _| live.contains(id)); + for (index, row) in all_rows.iter().enumerate() { + let alias_cell = price::alias_cell(&selected, &row.model); + let alias_text = row + .alias + .clone() + .unwrap_or_else(|| alias_cell.value.clone()); + let mapped = !alias_text.trim().is_empty(); + let error = row_errors.get(&row.id).filter(|_| row.has_text()); + let invalid = row_errors.contains_key(&row.id) && !row.model.trim().is_empty(); + + let model_cell = { + let content = if row.is_new { + let input = self.price_input( + prices, + (row.id.clone(), Kind::Model), + &row.model, + "Model ID", + window, + cx, + ); + if prices.focus_row.as_deref() == Some(row.id.as_str()) { + prices.focus_row = None; + let input = input.clone(); + window.defer(cx, move |window, cx| { + input.update(cx, |state, cx| state.focus(window, cx)); + }); + } + self.field(&input, invalid, locked, false, window, cx) + .into_any_element() + } else { + div() + .when(row.removed, |text| text.text_color(muted).line_through()) + .child(row.model.clone()) + .into_any_element() + }; + cell(model_width) + .child(content) + .children(error.map(|error| { + div() + .mt_1() + .text_xs() + .text_color(danger) + .child(error.clone()) + })) + }; + + let mut line = h_flex() + .id(("usage-price-row", index)) + .items_center() + .border_b_1() + .border_color(border) + .when(index + 1 == all_rows.len(), |line| line.border_b_0()) + .hover(move |style| style.bg(hover_row)) + .child(model_cell); + if row.removed { + line = line.child( + cell(alias_width + 4. * rate_width).child( + div() + .text_color(muted) + .child("Automatic pricing after saving"), + ), + ); + } else { + let placeholder = if row.is_new { + "Optional" + } else { + alias_cell.placeholder + }; + let input = self.price_input( + prices, + (row.id.clone(), Kind::Alias), + &alias_text, + placeholder, + window, + cx, + ); + line = line.child( + cell(alias_width) + .child(self.field(&input, false, locked, false, window, cx)), + ); + if mapped { + line = line.child( + cell(4. * rate_width).child( + div() + .text_color(muted) + .child(format!("Counted as {}", alias_text.trim())), + ), + ); + } else { + for field in Field::ALL { + let saved = price::price_cell(&selected, &row.model, field); + let text = + row.values.get(field).map(str::to_owned).unwrap_or_else(|| { + if row.is_new { + String::new() + } else { + saved.value.clone() + } + }); + let placeholder = if row.is_new { + if field.optional() { + "Input rate" + } else { + "0.00" + } + } else { + saved.placeholder + }; + let input = self.price_input( + prices, + (row.id.clone(), Kind::Rate(field)), + &text, + placeholder, + window, + cx, + ); + line = line.child( + cell(rate_width) + .child(self.field(&input, false, locked, true, window, cx)), + ); + } + } + } + let (icon, label) = if row.removed { + (Icon::new(Icons::RotateCcw), "Undo reset") + } else if row.is_new { + (Icon::new(IconName::Close), "Remove row") + } else { + (Icon::new(Icons::RotateCcw), "Reset to automatic") + }; + let id = row.id.clone(); + line = line.child( + cell(ACTION_WIDTH).child( + icon_button( + ("usage-price-action", index), + icon.size(px(14.)), + label, + !locked, + cx, + ) + .when(!locked, |button| { + button.on_click( + cx.listener(move |this, _, _, cx| this.price_row_action(&id, cx)), + ) + }), + ), + ); + body = body.child(line); + } + + let help = format!( + "Blank cache rates use the input price. Enter 0 for free tokens. A mapped model\u{2019}s usage counts as the model it maps to.{}", + if selected.len() > 1 { + " Mixed cells keep each environment\u{2019}s rate until you edit them." + } else { + "" + } + ); + v_flex() + .gap_4() + .child( + div() + .min_w_0() + .overflow_hidden() + .rounded(px(8.)) + .border_1() + .border_color(border) + .child( + div() + .id("usage-prices-table") + .overflow_x_scroll() + .text_xs() + .child(v_flex().w(px(table_width)).child(header).child(body)), + ), + ) + .child(div().text_xs().text_color(muted).child(help)) + .into_any_element() + }; + + let status = prices.attempt.as_ref().map(|attempt| { + let (text, bad) = match &attempt.outcome { + Outcome::Failed(error) => (format!("Not saved \u{b7} {error}"), true), + Outcome::Saved => ("Saved".to_owned(), false), + Outcome::Saving => ("Saving\u{2026}".to_owned(), false), + }; + h_flex() + .text_xs() + .justify_between() + .gap_3() + .child( + div() + .min_w_0() + .truncate() + .text_color(muted) + .child(environment.label.clone()), + ) + .child( + div() + .when(bad, |text| text.text_right()) + .text_color(if bad { danger } else { muted }) + .child(text), + ) + }); + + let save_disabled = pending + || (!has_changes && !failed) + || (!failed && (!row_errors.is_empty() || selected.is_empty())); + let save_label = if pending { + "Saving\u{2026}" + } else if failed { + "Retry failed saves" + } else { + "Save changes" + }; + let discard_label = if failed { + "Discard pending changes" + } else { + "Discard changes" + }; + + let header = v_flex() + .flex_shrink_0() + .gap_2() + .p_6() + .pb_3() + .child( + div() + .text_size(px(20.)) + .line_height(px(27.5)) + .font_weight(FontWeight::SEMIBOLD) + .child("Custom model prices"), + ) + .child( + div() + .text_size(px(14.)) + .line_height(px(20.)) + .text_color(muted) + .child( + "Prices and mappings apply to all past and future usage on the environments you select.", + ), + ); + let panel = v_flex() + .id("usage-prices-panel") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .gap_4() + .px_6() + .py_1() + .child(top) + .child(table) + .children(status); + let footer = h_flex() + .flex_shrink_0() + .items_center() + .justify_between() + .gap_2() + .px_6() + .py_4() + .child(div().text_xs().text_color(muted).child(if has_changes { + format!("Changes apply to {destination}") + } else { + String::new() + })) + .child( + h_flex() + .gap_2() + .when(has_changes, |row| { + row.child( + button( + "usage-prices-discard", + discard_label, + Variant::Ghost, + !pending, + cx, + ) + .when(!pending, |button| { + button + .on_click(cx.listener(|this, _, _, cx| this.discard_prices(cx))) + }), + ) + }) + .child( + button( + "usage-prices-save", + save_label, + Variant::Primary, + !save_disabled, + cx, + ) + .when(!save_disabled, |button| { + button.on_click( + cx.listener(move |this, _, _, cx| this.save_prices(failed, cx)), + ) + }), + ), + ); + let close = ghost_icon_button( + "usage-prices-close", + Icon::new(IconName::Close).size(px(16.)), + "Close".into(), + !pending, + cx, + ) + .size(px(32.)) + .absolute() + .right(px(8.)) + .top(px(8.)) + .on_click(cx.listener(|this, _, _, cx| this.close_prices(cx))); + + div() + .absolute() + .inset_0() + .child( + div() + .id("usage-prices-backdrop") + .absolute() + .inset_0() + .occlude() + .bg(backdrop) + .on_click(cx.listener(|this, _, _, cx| this.close_prices(cx))), + ) + .child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .p_4() + .child( + v_flex() + .relative() + .occlude() + .w(px(dialog_width)) + .max_h(px(vh - 32.)) + .rounded(px(16.)) + .border_1() + .border_color(border) + .bg(background) + .text_color(foreground) + .shadow_lg() + .child(header) + .child(panel) + .child(footer) + .child(close), + ), + ) + .into_any_element() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::usage_prices::Price; + use std::collections::BTreeMap; + + fn target(models: &[(&str, &str)]) -> PriceTarget { + let prices = models + .iter() + .map(|(model, _)| { + ( + (*model).to_owned(), + Price { + input: 1.0, + output: 2.0, + cache_read: None, + cache_write: None, + }, + ) + }) + .collect(); + let aliases = models + .iter() + .filter(|(_, alias)| !alias.is_empty()) + .map(|(model, alias)| ((*model).to_owned(), (*alias).to_owned())) + .collect(); + PriceTarget { + label: "Local".to_owned(), + prices: Some(prices), + aliases: Some(aliases), + unavailable: None, + } + } + + fn new_row(id: &str, model: &str) -> Draft { + Draft::new(id.to_owned(), model, true) + } + + #[test] + fn rows_list_saved_models_sorted_then_new_rows() { + let targets = [target(&[("b", ""), ("a", "")])]; + let drafts = [new_row("new:0", "c")]; + let ids: Vec<_> = rows(&targets, &drafts, false) + .into_iter() + .map(|row| row.id) + .collect(); + assert_eq!(ids, ["model:a", "model:b", "new:0"]); + } + + #[test] + fn an_attempt_shows_a_saved_model_by_its_new_row_alone() { + let targets = [target(&[("a", "")])]; + let drafts = [new_row("new:0", "a")]; + assert_eq!(rows(&targets, &drafts, false).len(), 2); + let after = rows(&targets, &drafts, true); + assert_eq!(after.len(), 1); + assert!(after[0].is_new); + } + + #[test] + fn new_rows_need_a_model_and_must_not_repeat_one() { + let targets = [target(&[("a", "")])]; + let mut empty = new_row("new:0", ""); + empty.values.set(Field::Input, "1".to_owned()); + let mut repeat = new_row("new:1", "a"); + repeat.values.set(Field::Input, "1".to_owned()); + let drafts = [empty, repeat]; + let found = errors(&targets, &drafts, false); + assert_eq!(found["new:0"], "Enter a model ID."); + assert_eq!( + found["new:1"], + "This model already has a row. Edit its prices there." + ); + assert!(!errors(&targets, &drafts, true).contains_key("new:1")); + } + + #[test] + fn editing_back_to_the_saved_value_drops_the_draft() { + let targets = [target(&[("a", "")])]; + let mut state = Prices::new(true); + let row = Draft::new("model:a".to_owned(), "a", false); + state.edit_cell(&targets, &row, Field::Input, "5".to_owned()); + assert_eq!(state.staged().len(), 1); + let row = state.drafts[0].clone(); + state.edit_cell(&targets, &row, Field::Input, "1".to_owned()); + assert!(state.drafts.is_empty()); + } + + #[test] + fn editing_clears_the_attempt_but_dropping_a_row_keeps_it() { + let mut state = Prices::new(true); + state.attempt = Some(Attempt { + drafts: Vec::new(), + outcome: Outcome::Saved, + }); + let row = Draft::new("model:a".to_owned(), "a", false); + state.update_or_drop(row.clone()); + assert!(state.attempt.is_some()); + state.update_draft(row); + assert!(state.attempt.is_none()); + } + + #[test] + fn the_store_keeps_a_saved_change_until_the_settings_differ() { + let mut store = PriceStore::default(); + store.refresh((Some(BTreeMap::new()), Some(BTreeMap::new()))); + store.apply(&[Change::Alias("a".to_owned(), Some("b".to_owned()))]); + assert_eq!(store.local.1.as_ref().unwrap()["a"], "b"); + store.refresh((Some(BTreeMap::new()), Some(BTreeMap::new()))); + assert_eq!(store.local.1.as_ref().unwrap()["a"], "b"); + let mut next = BTreeMap::new(); + next.insert("z".to_owned(), "y".to_owned()); + store.refresh((Some(BTreeMap::new()), Some(next))); + assert!(!store.local.1.as_ref().unwrap().contains_key("a")); + } +} diff --git a/apps/desktop-gpui/src/usage.rs b/apps/desktop-gpui/src/usage.rs index 4e4ae78718f1..b03592a638eb 100644 --- a/apps/desktop-gpui/src/usage.rs +++ b/apps/desktop-gpui/src/usage.rs @@ -158,10 +158,15 @@ impl Limits { } } +/// `Math.round`: halves round toward positive infinity, so -0.5 is 0 and 2.5 is 3. +pub fn js_round(value: f64) -> i64 { + (value + 0.5).floor() as i64 +} + impl Window { /// `remainingPercent`. pub fn remaining_percent(&self) -> i64 { - (100. - self.used_percent.clamp(0., 100.)).round() as i64 + js_round(100. - self.used_percent.clamp(0., 100.)) } /// `elapsedShare`: `None` without a reset time and window length. @@ -176,7 +181,7 @@ impl Window { /// `paceRemainingPercent`: the share of the window still to come, where even spending would sit. pub fn pace_remaining_percent(&self, now: i64) -> Option { - Some(((1. - self.elapsed_share(now)?) * 100.).round() as i64) + Some(js_round((1. - self.elapsed_share(now)?) * 100.)) } /// `paceOf`. @@ -322,6 +327,15 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn js_round_rounds_halves_toward_positive_infinity() { + assert_eq!(js_round(-0.5), 0); + assert_eq!(js_round(2.5), 3); + assert_eq!(js_round(-1.5), -1); + assert_eq!(js_round(2.4), 2); + assert_eq!(js_round(-2.6), -3); + } + fn window(used: f64, resets: Option, mins: Option) -> Window { Window { id: "five_hour".to_owned(), diff --git a/apps/desktop-gpui/src/usage_data.rs b/apps/desktop-gpui/src/usage_data.rs index d0a5aa7fb4e1..c83e405d88af 100644 --- a/apps/desktop-gpui/src/usage_data.rs +++ b/apps/desktop-gpui/src/usage_data.rs @@ -51,6 +51,19 @@ impl Provider { Self::ORDER.iter().position(|p| *p == self).unwrap_or(0) } + pub fn wire(self) -> &'static str { + match self { + Self::Codex => "codex", + Self::Claude => "claude", + Self::Grok => "grok", + Self::Cursor => "cursor", + Self::OpenCode => "opencode", + Self::Antigravity => "antigravity", + Self::Pi => "pi", + Self::Muse => "muse", + } + } + fn from_wire(value: &str) -> Option { Some(match value { "codex" => Self::Codex, @@ -1383,10 +1396,79 @@ pub fn build_chart( // Preferences, usagePagePreferences.ts. The web keeps them in localStorage; this app has no // such store, so they live in a small JSON file under the app's home directory. +/// `hiddenProviders`: the providers the breadcrumb's picker turned off, one bit per `Provider::slot`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Hidden(u8); + +impl Hidden { + pub fn contains(self, provider: Provider) -> bool { + self.0 & (1 << provider.slot()) != 0 + } + + pub fn is_empty(self) -> bool { + self.0 == 0 + } + + /// `PROVIDER_ORDER.filter((provider) => !hiddenProviders.has(provider))`. + pub fn visible(self) -> Vec { + Provider::ORDER + .into_iter() + .filter(|provider| !self.contains(*provider)) + .collect() + } + + pub fn all() -> Self { + Self(((1u16 << Provider::ORDER.len()) - 1) as u8) + } + + /// The picker's `onCheckedChange` for one provider row. + pub fn with(self, provider: Provider, visible: bool) -> Self { + let bit = 1 << provider.slot(); + Self(if visible { self.0 & !bit } else { self.0 | bit }) + } + + /// The trigger label (`UsageProviderFilter`). + pub fn label(self) -> String { + let visible = self.visible(); + match visible.len() { + n if n == Provider::ORDER.len() => "All providers".to_owned(), + 0 => "No providers".to_owned(), + 1 => visible[0].label().to_owned(), + n => format!("{n} providers"), + } + } +} + +impl Summary { + /// `withoutProviders`: buckets and sources of hidden providers never reach the merge. + pub fn without(&self, hidden: Hidden) -> Self { + if hidden.is_empty() { + return self.clone(); + } + Self { + contract_version: self.contract_version, + time_zone: self.time_zone.clone(), + buckets: self + .buckets + .iter() + .filter(|bucket| !hidden.contains(bucket.provider)) + .cloned() + .collect(), + sources: self + .sources + .iter() + .filter(|source| !hidden.contains(source.provider)) + .cloned() + .collect(), + } + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Prefs { pub metric: Metric, pub window_days: u32, + pub hidden: Hidden, } impl Default for Prefs { @@ -1394,6 +1476,7 @@ impl Default for Prefs { Self { metric: Metric::Limits, window_days: 30, + hidden: Hidden::default(), } } } @@ -1413,22 +1496,38 @@ impl Prefs { n @ (1 | 7 | 30 | 90) => n as u32, _ => return None, }; + let mut hidden = Hidden::default(); + // The web's schema rejects the whole record when an entry is not a provider kind. + if let Some(list) = value.get("hiddenProviders") { + for entry in list.as_array()? { + hidden = hidden.with(Provider::from_wire(entry.as_str()?)?, false); + } + } Some(Self { metric, window_days, + hidden, }) } pub fn to_json(self) -> String { - json!({ + let hidden: Vec<&str> = Provider::ORDER + .into_iter() + .filter(|provider| self.hidden.contains(*provider)) + .map(Provider::wire) + .collect(); + let mut value = json!({ "metric": match self.metric { Metric::Cost => "cost", Metric::Tokens => "tokens", Metric::Limits => "limits", }, "windowDays": self.window_days, - }) - .to_string() + }); + if !hidden.is_empty() { + value["hiddenProviders"] = json!(hidden); + } + value.to_string() } pub fn path() -> Option { @@ -2092,19 +2191,58 @@ mod tests { Prefs::default(), Prefs { metric: Metric::Limits, - window_days: 30 + window_days: 30, + hidden: Hidden::default(), } ); let saved = Prefs { metric: Metric::Cost, window_days: 7, + hidden: Hidden::default() + .with(Provider::Claude, false) + .with(Provider::Muse, false), }; assert_eq!(Prefs::parse(&saved.to_json()), Some(saved)); + assert!( + saved + .to_json() + .contains(r#""hiddenProviders":["claude","muse"]"#) + ); + assert!(!Prefs::default().to_json().contains("hiddenProviders")); + assert_eq!( + Prefs::parse(r#"{"metric":"cost","windowDays":7,"hiddenProviders":["codex"]}"#) + .map(|prefs| prefs.hidden.visible().len()), + Some(7) + ); + assert_eq!( + Prefs::parse(r#"{"metric":"cost","windowDays":7,"hiddenProviders":["nope"]}"#), + None + ); assert_eq!(Prefs::parse(r#"{"metric":"cost","windowDays":14}"#), None); assert_eq!(Prefs::parse(r#"{"metric":"nope","windowDays":7}"#), None); assert_eq!(Prefs::parse("not json"), None); } + #[test] + fn hidden_providers_leave_the_merge_and_label_the_picker() { + let value = summary( + vec![bucket(|_| {}), bucket(|b| b["provider"] = json!("codex"))], + vec![("claude", "/a", 1), ("codex", "/b", 1)], + 6, + ); + let decoded = Summary::decode(&value).expect("decodes"); + let hidden = Hidden::default().with(Provider::Codex, false); + let filtered = decoded.without(hidden); + assert_eq!(filtered.buckets.len(), 1); + assert_eq!(filtered.sources.len(), 1); + assert_eq!(active_providers(&merge(&filtered)), [Provider::Claude]); + assert_eq!(decoded.without(Hidden::default()), decoded); + assert_eq!(Hidden::default().label(), "All providers"); + assert_eq!(hidden.label(), "7 providers"); + assert_eq!(Hidden::all().label(), "No providers"); + assert_eq!(Hidden::all().with(Provider::Pi, true).label(), "Pi"); + } + #[test] fn decoding_rejects_a_summary_missing_required_fields() { let mut value = summary(vec![], vec![], 6); diff --git a/apps/desktop-gpui/src/usage_limits.rs b/apps/desktop-gpui/src/usage_limits.rs index e70bb72c525d..4439f99cb977 100644 --- a/apps/desktop-gpui/src/usage_limits.rs +++ b/apps/desktop-gpui/src/usage_limits.rs @@ -507,7 +507,7 @@ fn pool_windows(accounts: &[Account], now: i64) -> Vec { Some(Reset { member, at: reset_millis(&entry.window)?, - restores_percent: (entry.refill_percent / count).round() as i64, + restores_percent: crate::usage::js_round(entry.refill_percent / count), }) }) .collect(); @@ -523,8 +523,8 @@ fn pool_windows(accounts: &[Account], now: i64) -> Vec { columns: (0..accounts.len()) .map(|account| slots.get(&account).copied()) .collect(), - used_percent: used.round() as i64, - remaining_percent: (100. - used).round() as i64, + used_percent: crate::usage::js_round(used), + remaining_percent: crate::usage::js_round(100. - used), pace, resets, members, diff --git a/apps/desktop-gpui/src/usage_prices.rs b/apps/desktop-gpui/src/usage_prices.rs new file mode 100644 index 000000000000..a92ce679afbe --- /dev/null +++ b/apps/desktop-gpui/src/usage_prices.rs @@ -0,0 +1,942 @@ +//! The Custom model prices dialog's rules: `usagePriceForm.ts`, `usagePriceTable.ts` and +//! `usagePriceTargets.ts`. + +use serde::Deserialize; +use serde_json::{Map, Value, json}; +use std::collections::{BTreeMap, HashMap, HashSet}; + +/// `UsageModelPriceOverride`; omitted cache rates use the input rate. +#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Price { + #[serde(rename = "inputCostPerMillionTokens")] + pub input: f64, + #[serde(rename = "outputCostPerMillionTokens")] + pub output: f64, + #[serde(default, rename = "cacheReadCostPerMillionTokens")] + pub cache_read: Option, + #[serde(default, rename = "cacheWriteCostPerMillionTokens")] + pub cache_write: Option, +} + +impl Eq for Price {} + +impl Price { + pub fn to_json(self) -> Value { + let mut map = Map::new(); + map.insert("inputCostPerMillionTokens".into(), json!(self.input)); + map.insert("outputCostPerMillionTokens".into(), json!(self.output)); + if let Some(value) = self.cache_read { + map.insert("cacheReadCostPerMillionTokens".into(), json!(value)); + } + if let Some(value) = self.cache_write { + map.insert("cacheWriteCostPerMillionTokens".into(), json!(value)); + } + Value::Object(map) + } + + fn rate(&self, field: Field) -> Option { + match field { + Field::Input => Some(self.input), + Field::Output => Some(self.output), + Field::CacheRead => self.cache_read, + Field::CacheWrite => self.cache_write, + } + } +} + +/// `USAGE_PRICE_FIELDS`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Field { + Input, + Output, + CacheRead, + CacheWrite, +} + +impl Field { + pub const ALL: [Field; 4] = [ + Field::Input, + Field::Output, + Field::CacheRead, + Field::CacheWrite, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::Input => "Input", + Self::Output => "Output", + Self::CacheRead => "Cache read", + Self::CacheWrite => "Cache write", + } + } + + pub fn optional(self) -> bool { + matches!(self, Self::CacheRead | Self::CacheWrite) + } + + pub fn slot(self) -> usize { + Self::ALL.iter().position(|f| *f == self).unwrap_or(0) + } +} + +/// `String(number)` as JavaScript prints it, so a saved rate shows as the web shows it. +pub fn js_number(value: f64) -> String { + let magnitude = value.abs(); + if magnitude != 0.0 && !(1e-6..1e21).contains(&magnitude) { + let text = format!("{value:e}"); + return match text.split_once('e') { + Some((mantissa, exponent)) if !exponent.starts_with('-') => { + format!("{mantissa}e+{exponent}") + } + _ => text, + }; + } + format!("{value}") +} + +/// `usagePriceForm(model, price)`'s four rate strings. +pub fn rates(price: Option<&Price>) -> [String; 4] { + Field::ALL.map(|field| { + price + .and_then(|price| price.rate(field)) + .map(js_number) + .unwrap_or_default() + }) +} + +fn is_digits(text: &str) -> bool { + text.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn is_separator(character: char) -> bool { + character == '.' || character == ',' +} + +/// `\d*[.,]?\d*`. +fn plain_number_prefix(text: &str) -> bool { + let rest = text.trim_start_matches(|c: char| c.is_ascii_digit()); + let rest = rest.strip_prefix(is_separator).unwrap_or(rest); + is_digits(rest) +} + +/// `\d+[.,]?\d*|[.,]\d+`. +fn mantissa(text: &str) -> bool { + let digits = text.len() - text.trim_start_matches(|c: char| c.is_ascii_digit()).len(); + if digits > 0 { + return plain_number_prefix(text); + } + text.strip_prefix(is_separator) + .is_some_and(|rest| !rest.is_empty() && is_digits(rest)) +} + +fn is_edit_shape(text: &str) -> bool { + let trimmed = text.trim(); + if plain_number_prefix(trimmed) { + return true; + } + let Some(at) = trimmed.find(['e', 'E']) else { + return false; + }; + let (head, tail) = (&trimmed[..at], &trimmed[at + 1..]); + let tail = tail.strip_prefix(['+', '-']).unwrap_or(tail); + mantissa(head) && is_digits(tail) +} + +/// `isUsagePriceEdit`: decimal and exponent edits and every deletion, including temporarily +/// invalid values; saving validates the result. +pub fn is_price_edit(previous: &str, next: &str) -> bool { + if is_edit_shape(next) { + return true; + } + let previous: Vec = previous.encode_utf16().collect(); + let next: Vec = next.encode_utf16().collect(); + if next.len() >= previous.len() { + return false; + } + let mut start = 0; + while start < next.len() && next[start] == previous[start] { + start += 1; + } + next[start..] == previous[previous.len() - (next.len() - start)..] +} + +/// `(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?`, anchored. +fn is_rate_shape(text: &str) -> bool { + let (head, exponent) = match text.find(['e', 'E']) { + Some(at) => (&text[..at], Some(&text[at + 1..])), + None => (text, None), + }; + if let Some(exponent) = exponent { + let digits = exponent.strip_prefix(['+', '-']).unwrap_or(exponent); + if digits.is_empty() || !is_digits(digits) { + return false; + } + } + let digits = head.len() - head.trim_start_matches(|c: char| c.is_ascii_digit()).len(); + if digits > 0 { + let rest = &head[digits..]; + return rest.is_empty() || rest.strip_prefix('.').is_some_and(is_digits); + } + head.strip_prefix('.') + .is_some_and(|fraction| !fraction.is_empty() && is_digits(fraction)) +} + +/// A row's text as the four inputs hold it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Form { + pub model: String, + pub rates: [String; 4], +} + +impl Form { + pub fn new(model: &str, price: Option<&Price>) -> Self { + Self { + model: model.to_owned(), + rates: rates(price), + } + } +} + +/// `parseUsagePriceForm`: blank cache prices use the input rate, an explicit zero means free. +pub fn parse_form(form: &Form) -> Option<(String, Price)> { + let model = form.model.trim(); + if model.is_empty() { + return None; + } + let mut parsed: [Option; 4] = [None; 4]; + for field in Field::ALL { + let raw = form.rates[field.slot()].trim().replacen(',', ".", 1); + if raw.is_empty() { + if field.optional() { + continue; + } + return None; + } + if !is_rate_shape(&raw) { + return None; + } + let value: f64 = raw.parse().ok()?; + if !value.is_finite() || value < 0.0 { + return None; + } + parsed[field.slot()] = Some(value); + } + Some(( + model.to_owned(), + Price { + input: parsed[Field::Input.slot()]?, + output: parsed[Field::Output.slot()]?, + cache_read: parsed[Field::CacheRead.slot()], + cache_write: parsed[Field::CacheWrite.slot()], + }, + )) +} + +/// One environment's price overrides and model mappings, as its settings hold them. +pub type Snapshot = ( + Option>, + Option>, +); + +/// `UsagePriceTarget`: one environment the dialog applies to. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Target { + pub label: String, + pub prices: Option>, + pub aliases: Option>, + pub unavailable: Option, +} + +impl Target { + fn price(&self, model: &str) -> Option<&Price> { + self.prices.as_ref()?.get(model) + } + + fn alias(&self, model: &str) -> Option<&str> { + self.aliases.as_ref()?.get(model).map(String::as_str) + } +} + +/// The rate inputs a draft edited; a missing slot keeps the saved value. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Values([Option; 4]); + +impl Values { + pub fn get(&self, field: Field) -> Option<&str> { + self.0[field.slot()].as_deref() + } + + pub fn set(&mut self, field: Field, value: String) { + self.0[field.slot()] = Some(value); + } + + pub fn remove(&mut self, field: Field) { + self.0[field.slot()] = None; + } + + /// `Object.keys(values).length === 0`. + pub fn is_empty(&self) -> bool { + self.0.iter().all(Option::is_none) + } + + fn all_blank(&self) -> bool { + self.0.iter().flatten().all(|value| value.trim().is_empty()) + } + + fn any_filled(&self) -> bool { + self.0.iter().flatten().any(|value| !value.is_empty()) + } +} + +/// `UsagePriceDraft`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Draft { + pub id: String, + pub model: String, + pub is_new: bool, + pub values: Values, + /// Edited "Map to" model. Absent keeps each environment's mapping; blank removes it. + pub alias: Option, + pub removed: bool, +} + +impl Draft { + pub fn new(id: String, model: &str, is_new: bool) -> Self { + Self { + id, + model: model.to_owned(), + is_new, + values: Values::default(), + alias: None, + removed: false, + } + } + + /// Whether any error text would be shown: the model or a rate has text. + pub fn has_text(&self) -> bool { + !self.model.trim().is_empty() || self.values.any_filled() + } +} + +/// `isEmptyUsagePriceDraft`. +pub fn is_empty_draft(draft: &Draft) -> bool { + draft.is_new + && draft.model.trim().is_empty() + && draft.alias.as_deref().unwrap_or("").trim().is_empty() + && draft.values.all_blank() +} + +/// What a cell shows when it is empty, and its text. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cell { + pub value: String, + pub placeholder: &'static str, +} + +impl Cell { + /// The web's `matchesSaved` guard: a mixed or unavailable cell never matches. + pub fn is_comparable(&self) -> bool { + self.placeholder != "Mixed" && self.placeholder != "Unavailable" + } +} + +/// `usageAliasCell`. +pub fn alias_cell(targets: &[Target], model: &str) -> Cell { + if targets.iter().any(|target| target.aliases.is_none()) { + return Cell { + value: String::new(), + placeholder: "Unavailable", + }; + } + let values: Vec<&str> = targets + .iter() + .map(|target| target.alias(model).unwrap_or("")) + .collect(); + if values.iter().any(|value| *value != values[0]) { + return Cell { + value: String::new(), + placeholder: "Mixed", + }; + } + Cell { + value: values.first().copied().unwrap_or("").to_owned(), + placeholder: "None", + } +} + +/// `usagePriceCell`. +pub fn price_cell(targets: &[Target], model: &str, field: Field) -> Cell { + let values: Vec> = targets + .iter() + .map(|target| { + target + .price(model) + .map(|price| rates(Some(price))[field.slot()].clone()) + }) + .collect(); + if targets.iter().any(|target| target.prices.is_none()) { + return Cell { + value: String::new(), + placeholder: "Unavailable", + }; + } + if values.iter().any(|value| *value != values[0]) { + return Cell { + value: String::new(), + placeholder: "Mixed", + }; + } + let first = values.first(); + Cell { + value: first.cloned().flatten().unwrap_or_default(), + placeholder: if matches!(first, Some(None)) { + "Automatic" + } else if field.optional() { + "Input rate" + } else { + "0.00" + }, + } +} + +/// `UsagePriceChange`; `None` removes the model's custom price or mapping. +#[derive(Clone, Debug, PartialEq)] +pub enum Change { + Price(String, Option), + Alias(String, Option), +} + +/// `usagePriceTableChanges`. +pub fn table_changes(target: &Target, drafts: &[Draft]) -> (Vec, HashMap) { + let mut changes = Vec::new(); + let mut errors: HashMap = HashMap::new(); + for draft in drafts { + if is_empty_draft(draft) { + continue; + } + let model = draft.model.trim(); + let current_alias = target.alias(model); + if draft.removed { + if target.price(model).is_some() { + changes.push(Change::Price(model.to_owned(), None)); + } + if current_alias.is_some() { + changes.push(Change::Alias(model.to_owned(), None)); + } + continue; + } + let alias = draft + .alias + .as_deref() + .or(current_alias) + .unwrap_or("") + .trim() + .to_owned(); + if !alias.is_empty() { + if draft.alias.is_none() && !draft.values.is_empty() { + errors.insert( + draft.id.clone(), + format!( + "{} maps this model. Remove it from \"Apply to\" or clear its mapping first.", + target.label + ), + ); + continue; + } + if model.is_empty() { + errors.insert(draft.id.clone(), "Enter a model ID.".to_owned()); + } else if alias == model { + errors.insert(draft.id.clone(), "Map to a different model.".to_owned()); + } else { + if Some(alias.as_str()) != current_alias { + changes.push(Change::Alias(model.to_owned(), Some(alias))); + } + if target.price(model).is_some() { + changes.push(Change::Price(model.to_owned(), None)); + } + } + continue; + } + if current_alias.is_some() { + changes.push(Change::Alias(model.to_owned(), None)); + if target.price(model).is_none() && draft.values.all_blank() { + continue; + } + } + let original = Form::new(model, target.price(model)); + let mut form = original.clone(); + for field in Field::ALL { + if let Some(value) = draft.values.get(field) { + form.rates[field.slot()] = value.to_owned(); + } + } + match parse_form(&form) { + None => { + let missing = Field::ALL + .into_iter() + .find(|field| !field.optional() && form.rates[field.slot()].trim().is_empty()); + let message = if model.is_empty() { + "Enter a model ID.".to_owned() + } else if let Some(field) = missing { + format!("{} is required on {}.", field.label(), target.label) + } else { + "Use non-negative numbers for prices.".to_owned() + }; + errors.insert(draft.id.clone(), message); + } + Some((parsed_model, price)) => { + let next = Form::new(model, Some(&price)); + if original.rates != next.rates { + changes.push(Change::Price(parsed_model, Some(price))); + } + } + } + } + // Check the final batch so clearing a mapping and reversing it in one save stays valid. + let mut aliases: HashMap = target + .aliases + .iter() + .flatten() + .map(|(model, alias)| (model.clone(), alias.clone())) + .collect(); + for change in &changes { + if let Change::Alias(model, alias) = change { + match alias { + None => { + aliases.remove(model); + } + Some(alias) => { + aliases.insert(model.clone(), alias.clone()); + } + } + } + } + for draft in drafts { + if draft.removed || errors.contains_key(&draft.id) { + continue; + } + let mut model = draft.model.trim().to_owned(); + let mut seen: HashSet = HashSet::new(); + while let Some(next) = aliases.get(&model) { + if !seen.insert(model.clone()) { + errors.insert( + draft.id.clone(), + format!("Model mappings cannot form a cycle on {}.", target.label), + ); + break; + } + model = next.clone(); + } + } + (changes, errors) +} + +/// `usagePriceTableErrors`: the errors of every environment that can take the changes. +pub fn table_errors(targets: &[Target], drafts: &[Draft]) -> HashMap { + let mut errors = HashMap::new(); + for target in targets + .iter() + .filter(|target| target.unavailable.is_none() && target.prices.is_some()) + { + errors.extend(table_changes(target, drafts).1); + } + errors +} + +/// What `writeUsagePrices` does for one environment. +#[derive(Clone, Debug, PartialEq)] +pub enum Write { + Failed(String), + /// Nothing to change on this environment. + Saved, + Patch(Value), +} + +/// The `server.updateSettings` patch of one environment's changes, or why it cannot be sent. +pub fn plan_write(target: &Target, changes: &[Change]) -> Write { + if let Some(reason) = &target.unavailable { + return Write::Failed(reason.clone()); + } + let has_alias = changes + .iter() + .any(|change| matches!(change, Change::Alias(..))); + if has_alias && target.aliases.is_none() { + return Write::Failed("Update server to map models".to_owned()); + } + if changes.is_empty() { + return Write::Saved; + } + let mut prices = Map::new(); + let mut aliases = Map::new(); + for change in changes { + match change { + Change::Price(model, price) => { + prices.insert(model.clone(), price.map_or(Value::Null, Price::to_json)); + } + Change::Alias(model, alias) => { + aliases.insert( + model.clone(), + alias.clone().map_or(Value::Null, Value::from), + ); + } + } + } + let mut patch = Map::new(); + if !prices.is_empty() { + patch.insert("usagePriceOverrides".into(), Value::Object(prices)); + } + if !aliases.is_empty() { + patch.insert("usageModelAliases".into(), Value::Object(aliases)); + } + Write::Patch(json!({ "patch": Value::Object(patch) })) +} + +/// The server's settings after a successful patch: the same merge its reducer applies, so the +/// dialog shows the saved rows without waiting for a settings read. +pub fn apply_changes(target: &mut Target, changes: &[Change]) { + for change in changes { + match change { + Change::Price(model, price) => { + if let Some(prices) = target.prices.as_mut() { + match price { + Some(price) => { + prices.insert(model.clone(), *price); + } + None => { + prices.remove(model); + } + } + } + } + Change::Alias(model, alias) => { + if let Some(aliases) = target.aliases.as_mut() { + match alias { + Some(alias) => { + aliases.insert(model.clone(), alias.clone()); + } + None => { + aliases.remove(model); + } + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn price(input: f64, output: f64) -> Price { + Price { + input, + output, + cache_read: None, + cache_write: None, + } + } + + fn target(prices: &[(&str, Price)], aliases: &[(&str, &str)]) -> Target { + Target { + label: "Local".to_owned(), + prices: Some( + prices + .iter() + .map(|(model, price)| ((*model).to_owned(), *price)) + .collect(), + ), + aliases: Some( + aliases + .iter() + .map(|(model, alias)| ((*model).to_owned(), (*alias).to_owned())) + .collect(), + ), + unavailable: None, + } + } + + fn draft(id: &str, model: &str, is_new: bool, values: &[(Field, &str)]) -> Draft { + let mut draft = Draft::new(id.to_owned(), model, is_new); + for (field, value) in values { + draft.values.set(*field, (*value).to_owned()); + } + draft + } + + #[test] + fn rates_print_like_javascript() { + assert_eq!(js_number(3.0), "3"); + assert_eq!(js_number(0.5), "0.5"); + assert_eq!(js_number(1e21), "1e+21"); + assert_eq!(js_number(1e-7), "1e-7"); + assert_eq!(js_number(0.0), "0"); + } + + #[test] + fn edits_allow_partial_numbers_and_every_deletion() { + for ok in [ + "", "1", "1.", ".5", "1,5", " 2 ", "1e", "1e-", "1.5e+3", ".5e2", + ] { + assert!(is_price_edit("x", ok), "{ok:?}"); + } + for bad in ["abc", "1..2", "-1", "e5", "1e5e", "1 2"] { + assert!(!is_price_edit("1", bad), "{bad:?}"); + } + // A deletion from a value that is not a number is still an edit. + assert!(is_price_edit("1e5x", "1ex")); + assert!(is_price_edit("1a2", "1a")); + assert!(!is_price_edit("12", "1a2")); + assert!(!is_price_edit("1a", "1b")); + } + + #[test] + fn forms_parse_like_the_web() { + let form = |model: &str, rates: [&str; 4]| Form { + model: model.to_owned(), + rates: rates.map(str::to_owned), + }; + assert_eq!( + parse_form(&form(" m ", ["1,5", "2e1", "", "0"])), + Some(( + "m".to_owned(), + Price { + input: 1.5, + output: 20.0, + cache_read: None, + cache_write: Some(0.0), + } + )) + ); + assert_eq!(parse_form(&form("", ["1", "1", "", ""])), None); + assert_eq!(parse_form(&form("m", ["1", "", "", ""])), None); + assert_eq!(parse_form(&form("m", ["1", "-1", "", ""])), None); + assert_eq!(parse_form(&form("m", ["1", "1e", "", ""])), None); + assert_eq!(parse_form(&form("m", ["1", "1e999", "", ""])), None); + assert!(parse_form(&form("m", [".5", "5.", "", ""])).is_some()); + } + + #[test] + fn cells_show_mixed_and_unavailable() { + let a = target(&[("m", price(1.0, 2.0))], &[]); + let b = target(&[("m", price(1.0, 3.0))], &[("m", "n")]); + let both = [a.clone(), b.clone()]; + assert_eq!(price_cell(&both, "m", Field::Input).value, "1"); + assert_eq!(price_cell(&both, "m", Field::Output).placeholder, "Mixed"); + assert_eq!(alias_cell(&both, "m").placeholder, "Mixed"); + assert_eq!( + price_cell(std::slice::from_ref(&a), "x", Field::Input).placeholder, + "Automatic" + ); + assert_eq!( + price_cell(std::slice::from_ref(&a), "m", Field::CacheRead).placeholder, + "Input rate" + ); + assert_eq!( + alias_cell(std::slice::from_ref(&a), "m").placeholder, + "None" + ); + let mut missing = b; + missing.prices = None; + missing.aliases = None; + assert_eq!( + price_cell(&[a.clone(), missing.clone()], "m", Field::Input).placeholder, + "Unavailable" + ); + assert_eq!(alias_cell(&[a, missing], "m").placeholder, "Unavailable"); + assert!( + !Cell { + value: String::new(), + placeholder: "Mixed" + } + .is_comparable() + ); + } + + #[test] + fn changes_cover_edits_resets_and_mappings() { + let t = target( + &[("old", price(1.0, 2.0)), ("both", price(1.0, 1.0))], + &[("map", "old")], + ); + let (changes, errors) = table_changes( + &t, + &[ + draft( + "new:0", + "fresh", + true, + &[(Field::Input, "3"), (Field::Output, "4")], + ), + draft("model:old", "old", false, &[(Field::Output, "5")]), + { + let mut removed = Draft::new("model:both".into(), "both", false); + removed.removed = true; + removed + }, + { + let mut mapped = Draft::new("new:1".into(), "alias", true); + mapped.alias = Some("old".into()); + mapped + }, + ], + ); + assert!(errors.is_empty(), "{errors:?}"); + assert_eq!( + changes, + vec![ + Change::Price("fresh".into(), Some(price(3.0, 4.0))), + Change::Price("old".into(), Some(price(1.0, 5.0))), + Change::Price("both".into(), None), + Change::Alias("alias".into(), Some("old".into())), + ] + ); + } + + #[test] + fn unchanged_rows_and_empty_drafts_change_nothing() { + let t = target(&[("old", price(1.0, 2.0))], &[]); + let (changes, errors) = table_changes( + &t, + &[ + draft("new:0", "", true, &[]), + draft("model:old", "old", false, &[(Field::Input, "1.0")]), + ], + ); + assert!(changes.is_empty() && errors.is_empty()); + } + + #[test] + fn errors_use_the_webs_wording() { + let t = target(&[("old", price(1.0, 2.0))], &[("map", "old")]); + let errors = |drafts: &[Draft]| table_changes(&t, drafts).1; + let message = |drafts: &[Draft], id: &str| errors(drafts).remove(id).unwrap_or_default(); + assert_eq!( + message(&[draft("a", "", true, &[(Field::Input, "1")])], "a"), + "Enter a model ID." + ); + assert_eq!( + message(&[draft("a", "x", true, &[(Field::Input, "1")])], "a"), + "Output is required on Local." + ); + assert_eq!( + message( + &[draft( + "a", + "x", + true, + &[(Field::Input, "1"), (Field::Output, "-")] + )], + "a" + ), + "Use non-negative numbers for prices." + ); + assert_eq!( + message( + &[draft("model:map", "map", false, &[(Field::Input, "1")])], + "model:map" + ), + "Local maps this model. Remove it from \"Apply to\" or clear its mapping first." + ); + let mut same = Draft::new("a".into(), "x", true); + same.alias = Some("x".into()); + assert_eq!(message(&[same], "a"), "Map to a different model."); + let mut cycle = Draft::new("a".into(), "old", true); + cycle.alias = Some("map".into()); + assert_eq!( + message(&[cycle], "a"), + "Model mappings cannot form a cycle on Local." + ); + } + + #[test] + fn clearing_a_mapping_and_reversing_it_in_one_save_is_valid() { + let t = target(&[], &[("a", "b")]); + let mut clear = Draft::new("model:a".into(), "a", false); + clear.alias = Some(String::new()); + let mut reverse = Draft::new("new:0".into(), "b", true); + reverse.alias = Some("a".into()); + let (changes, errors) = table_changes(&t, &[clear, reverse]); + assert!(errors.is_empty(), "{errors:?}"); + assert_eq!( + changes, + vec![ + Change::Alias("a".into(), None), + Change::Alias("b".into(), Some("a".into())), + ] + ); + } + + #[test] + fn errors_skip_unavailable_environments() { + let good = target(&[], &[]); + let mut offline = target(&[], &[]); + offline.unavailable = Some("Offline".into()); + let drafts = [draft("a", "", true, &[(Field::Input, "1")])]; + assert_eq!(table_errors(std::slice::from_ref(&good), &drafts).len(), 1); + assert!(table_errors(&[offline], &drafts).is_empty()); + } + + #[test] + fn writes_build_one_patch_per_environment() { + let t = target(&[("old", price(1.0, 2.0))], &[]); + let changes = [ + Change::Price("old".into(), None), + Change::Price( + "new".into(), + Some(Price { + cache_read: Some(0.0), + ..price(1.0, 2.0) + }), + ), + Change::Alias("x".into(), Some("y".into())), + Change::Alias("z".into(), None), + ]; + assert_eq!( + plan_write(&t, &changes), + Write::Patch(json!({"patch": { + "usagePriceOverrides": { + "old": null, + "new": { + "inputCostPerMillionTokens": 1.0, + "outputCostPerMillionTokens": 2.0, + "cacheReadCostPerMillionTokens": 0.0, + }, + }, + "usageModelAliases": {"x": "y", "z": null}, + }})) + ); + assert_eq!(plan_write(&t, &[]), Write::Saved); + let mut old_server = t.clone(); + old_server.aliases = None; + assert_eq!( + plan_write(&old_server, &changes), + Write::Failed("Update server to map models".into()) + ); + let mut offline = t; + offline.unavailable = Some("Offline".into()); + assert_eq!( + plan_write(&offline, &changes), + Write::Failed("Offline".into()) + ); + } + + #[test] + fn saved_changes_update_the_local_settings() { + let mut t = target(&[("old", price(1.0, 2.0))], &[("a", "b")]); + apply_changes( + &mut t, + &[ + Change::Price("old".into(), None), + Change::Price("new".into(), Some(price(3.0, 4.0))), + Change::Alias("a".into(), None), + Change::Alias("c".into(), Some("d".into())), + ], + ); + assert_eq!(t.prices.as_ref().map(BTreeMap::len), Some(1)); + assert_eq!(t.price("new"), Some(&price(3.0, 4.0))); + assert_eq!(t.alias("a"), None); + assert_eq!(t.alias("c"), Some("d")); + } +} From 4e7e0ab4fd71a985739af6ada390d4f159f7bbe3 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Sun, 8 Nov 2026 09:52:15 -0500 Subject: [PATCH 61/82] fix(desktop-gpui): prices dialog add button and keyboard focus match the web The add button was not the web's icon-xs ghost size, and the dialog had no keyboard focus ring or Tab containment. The button is now 24px with radius 8 and a 14px icon, the scrolling panel shows the inset focus-visible ring on keyboard focus only, and Tab cycles through the dialog's controls in DOM order inside a focus trap. --- apps/desktop-gpui/src/ui/usage_dialog.rs | 2 + .../src/ui/usage_dialog/prices.rs | 158 ++++++++++++++++-- 2 files changed, 146 insertions(+), 14 deletions(-) diff --git a/apps/desktop-gpui/src/ui/usage_dialog.rs b/apps/desktop-gpui/src/ui/usage_dialog.rs index 14cea6035559..10fcbc88d4ac 100644 --- a/apps/desktop-gpui/src/ui/usage_dialog.rs +++ b/apps/desktop-gpui/src/ui/usage_dialog.rs @@ -149,6 +149,7 @@ pub struct UsageDialog { menu: Option, prices: Option, price_store: prices::PriceStore, + price_focus: prices::Focus, } fn now_ms() -> i64 { @@ -188,6 +189,7 @@ impl UsageDialog { menu: None, prices: None, price_store: prices::PriceStore::default(), + price_focus: prices::Focus::new(cx), } } diff --git a/apps/desktop-gpui/src/ui/usage_dialog/prices.rs b/apps/desktop-gpui/src/ui/usage_dialog/prices.rs index d744d52307df..7eda904f4a22 100644 --- a/apps/desktop-gpui/src/ui/usage_dialog/prices.rs +++ b/apps/desktop-gpui/src/ui/usage_dialog/prices.rs @@ -5,7 +5,8 @@ use super::*; use crate::usage_prices::{ self as price, Change, Draft, Field, Snapshot, Target as PriceTarget, Write, }; -use gpui::Anchor; +use gpui::{Anchor, FocusHandle, ScrollHandle}; +use gpui_component::FocusTrapElement as _; use gpui_component::input::{Input, InputEvent, InputState}; use gpui_component::popover::Popover; use std::collections::{BTreeSet, HashMap, HashSet}; @@ -241,12 +242,76 @@ enum Variant { Ghost, } +/// The tab stops of the dialog, in the web's DOM order (`dialog.tsx:117-157`). +pub(super) struct Focus { + scope: FocusHandle, + panel: FocusHandle, + apply: FocusHandle, + add: FocusHandle, + discard: FocusHandle, + save: FocusHandle, + close: FocusHandle, + panel_scroll: ScrollHandle, + rows: HashMap, +} + +impl Focus { + pub(super) fn new(cx: &mut App) -> Self { + let stop = |cx: &mut App| cx.focus_handle().tab_stop(true); + Self { + scope: cx.focus_handle(), + panel: stop(cx), + apply: stop(cx), + add: stop(cx), + discard: stop(cx), + save: stop(cx), + close: stop(cx), + panel_scroll: ScrollHandle::new(), + rows: HashMap::new(), + } + } +} + +/// `focus-visible`: focused, and the last input was the keyboard. +fn focus_visible(handle: &FocusHandle, window: &Window) -> bool { + handle.is_focused(window) && window.last_input_was_keyboard() +} + +/// `focus-visible:ring-2 ring-inset ring-ring ring-offset-1 ring-offset-background` +/// (`button.tsx:9`, `scroll-area.tsx:54`): an inset ring of 2px, under an offset line of 1px +/// in the background color. `inset` is the border the ring sits inside of. +fn focus_ring(radius: f32, inset: f32, cx: &App) -> gpui::Div { + let theme = cx.theme(); + div() + .absolute() + .top(px(inset)) + .left(px(inset)) + .right(px(inset)) + .bottom(px(inset)) + .rounded(px(radius)) + .border_1() + .border_color(theme.background) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .rounded(px((radius - 1.).max(0.))) + .border_2() + .border_color(theme.ring), + ) +} + /// A `size="default"` button: `h-8`, `text-sm`, `font-medium`. fn button( id: &'static str, label: &'static str, variant: Variant, enabled: bool, + handle: &FocusHandle, + window: &Window, cx: &App, ) -> gpui::Stateful { let theme = cx.theme(); @@ -256,8 +321,12 @@ fn button( theme.accent, theme.foreground, ); + let handle = handle.clone().tab_stop(enabled); + let ring = enabled && focus_visible(&handle, window); div() .id(id) + .relative() + .track_focus(&handle) .h(px(32.)) .px(px(12.)) .flex() @@ -286,35 +355,47 @@ fn button( .when(enabled, |button| button.cursor_pointer()) .when(!enabled, |button| button.opacity(0.64)) .child(label) + .when(ring, |button| button.child(focus_ring(7., 1., cx))) } -/// A `size="icon-xs"` ghost button: `size-6`, 14px icon. +/// A ghost icon button (`button.tsx:21-34`): `icon-xs` is `size-6`, `icon` is `size-8`. +#[allow(clippy::too_many_arguments)] fn icon_button( id: impl Into, icon: impl IntoElement, - label: &'static str, + size: f32, + label: Option<&'static str>, enabled: bool, + handle: &FocusHandle, + window: &Window, cx: &App, ) -> gpui::Stateful { let theme = cx.theme(); let (accent, foreground) = (theme.accent, theme.foreground); + let handle = handle.clone().tab_stop(enabled); + let ring = enabled && focus_visible(&handle, window); div() .id(id) + .relative() + .track_focus(&handle) .flex_shrink_0() - .size(px(24.)) + .size(px(size)) .flex() .items_center() .justify_center() .rounded(px(8.)) + .border_1() + .border_color(gpui::transparent_black()) .text_color(foreground) .when(enabled, |button| { button .cursor_pointer() .hover(move |style| style.bg(accent)) - .tooltip(tooltip(label)) + .when_some(label, |button, label| button.tooltip(tooltip(label))) }) .when(!enabled, |button| button.opacity(0.64)) .child(icon) + .when(ring, |button| button.child(focus_ring(7., 1., cx))) } /// `Button variant="outline" size="sm"` as the "Apply to" menu trigger. @@ -323,6 +404,7 @@ struct ApplyTrigger { label: String, enabled: bool, open: bool, + ring: bool, } impl Selectable for ApplyTrigger { @@ -363,8 +445,10 @@ impl RenderOnce for ApplyTrigger { theme.popover }; let enabled = self.enabled; + let ring = self.ring; h_flex() .id("usage-prices-apply") + .relative() .min_w_0() .h(px(28.)) .px(px(10.)) @@ -384,6 +468,7 @@ impl RenderOnce for ApplyTrigger { .when(!enabled, |button| button.opacity(0.64)) .child(div().min_w_0().truncate().child(self.label)) .child(Icon::new(Icons::ChevronDown).size(px(14.)).flex_shrink_0()) + .when(ring, |button| button.child(focus_ring(7., 1., cx))) } } @@ -424,9 +509,10 @@ impl UsageDialog { } } - pub(super) fn open_prices(&mut self, _: &mut Window, cx: &mut Context) { + pub(super) fn open_prices(&mut self, window: &mut Window, cx: &mut Context) { self.menu = None; self.prices = Some(Prices::new(self.env_selected)); + window.focus(&self.price_focus.scope, cx); cx.notify(); } @@ -746,6 +832,28 @@ impl UsageDialog { destination.clone() }; + let apply_focus = self.price_focus.apply.clone(); + let add_focus = self.price_focus.add.clone(); + let discard_focus = self.price_focus.discard.clone(); + let save_focus = self.price_focus.save.clone(); + let close_focus = self.price_focus.close.clone(); + let panel_focus = self.price_focus.panel.clone(); + let scope_focus = self.price_focus.scope.clone(); + let panel_scroll = self.price_focus.panel_scroll.clone(); + let overflows = f32::from(panel_scroll.max_offset().y) > 0.; + let panel_focus = panel_focus.tab_stop(overflows); + let panel_ring = overflows && focus_visible(&panel_focus, window); + let row_focus: Vec = all_rows + .iter() + .map(|row| { + self.price_focus + .rows + .entry(row.id.clone()) + .or_insert_with(|| cx.focus_handle().tab_stop(true)) + .clone() + }) + .collect(); + let theme = cx.theme(); let (border, background, muted, foreground, danger) = ( theme.border, @@ -769,6 +877,7 @@ impl UsageDialog { let label = environment.label.clone(); let enabled = !(pending || has_changes); Popover::new("usage-prices-apply-menu") + .track_focus(&apply_focus) .anchor(Anchor::TopLeft) .open(self.menu == Some(Menu::Apply) && enabled) .p_0() @@ -783,6 +892,7 @@ impl UsageDialog { label: selection_label, enabled, open: false, + ring: enabled && focus_visible(&apply_focus, window), }) .content(move |_, _, cx| { let toggle = { @@ -880,8 +990,11 @@ impl UsageDialog { icon_button( "usage-prices-add", Icon::new(Icons::Plus).size(px(14.)), - "Add model price", + 24., + None, !locked, + &add_focus, + window, cx, ) .when(!locked, |button| { @@ -1040,8 +1153,11 @@ impl UsageDialog { icon_button( ("usage-price-action", index), icon.size(px(14.)), - label, + 24., + Some(label), !locked, + &row_focus[index], + window, cx, ) .when(!locked, |button| { @@ -1147,8 +1263,9 @@ impl UsageDialog { ); let panel = v_flex() .id("usage-prices-panel") - .flex_1() - .min_h_0() + .track_focus(&panel_focus) + .track_scroll(&panel_scroll) + .size_full() .overflow_y_scroll() .gap_4() .px_6() @@ -1156,6 +1273,12 @@ impl UsageDialog { .child(top) .child(table) .children(status); + let panel = div() + .relative() + .flex_1() + .min_h_0() + .child(panel) + .when(panel_ring, |region| region.child(focus_ring(16., 0., cx))); let footer = h_flex() .flex_shrink_0() .items_center() @@ -1178,6 +1301,8 @@ impl UsageDialog { discard_label, Variant::Ghost, !pending, + &discard_focus, + window, cx, ) .when(!pending, |button| { @@ -1192,6 +1317,8 @@ impl UsageDialog { save_label, Variant::Primary, !save_disabled, + &save_focus, + window, cx, ) .when(!save_disabled, |button| { @@ -1201,14 +1328,16 @@ impl UsageDialog { }), ), ); - let close = ghost_icon_button( + let close = icon_button( "usage-prices-close", Icon::new(IconName::Close).size(px(16.)), - "Close".into(), + 32., + None, !pending, + &close_focus, + window, cx, ) - .size(px(32.)) .absolute() .right(px(8.)) .top(px(8.)) @@ -1249,7 +1378,8 @@ impl UsageDialog { .child(header) .child(panel) .child(footer) - .child(close), + .child(close) + .focus_trap("usage-prices-trap", &scope_focus), ), ) .into_any_element() From a5e1d07f29caff2abf04964938775f32d422beb7 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 9 Nov 2026 01:56:35 -0500 Subject: [PATCH 62/82] feat(desktop-gpui): settings modal with a working General tab --- apps/desktop-gpui/src/assets.rs | 54 +- apps/desktop-gpui/src/main.rs | 4 + apps/desktop-gpui/src/settings.rs | 703 ++++++++++ apps/desktop-gpui/src/ui.rs | 26 +- apps/desktop-gpui/src/ui/settings.rs | 1223 +++++++++++++++++ .../src/ui/settings/components.rs | 405 ++++++ 6 files changed, 2407 insertions(+), 8 deletions(-) create mode 100644 apps/desktop-gpui/src/settings.rs create mode 100644 apps/desktop-gpui/src/ui/settings.rs create mode 100644 apps/desktop-gpui/src/ui/settings/components.rs diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 86dca7197943..230d53720f33 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -73,7 +73,24 @@ gpui_kit_assets::icon_assets!( X, Star, StarFill, - Zap + Zap, + Settings2, + Palette, + MessageSquareText, + MessagesSquare, + Keyboard, + Bot, + Globe, + Smartphone, + CalendarClock, + FolderTree, + ShieldCheck, + KeyRound, + Wrench, + FlaskConical, + Archive, + PanelsTopLeft, + Undo2 ] ); @@ -91,6 +108,17 @@ const MESSAGE_SQUARE_DASHED_SVG: &str = concat!( r#""#, ); +pub const SNAP_SHOT: &str = "icons/snap-shot.svg"; + +/// `SnapShotIcon` (SettingsSidebarNav.tsx:63-73), which lucide does not ship. +const SNAP_SHOT_SVG: &str = concat!( + r#""#, + r#""#, + r#""#, +); + /// The web's dev `SidebarStageBackdrop` art, dark palette, faded into the sidebar. pub const STAGE_DEV: &str = "images/stage-dev.svg"; /// The same art cropped like the web's `StageBackdropButtonArt`, for the send button. @@ -271,6 +299,9 @@ impl AssetSource for AppAssets { if path == MESSAGE_SQUARE_DASHED { return Ok(Some(Cow::Borrowed(MESSAGE_SQUARE_DASHED_SVG.as_bytes()))); } + if path == SNAP_SHOT { + return Ok(Some(Cow::Borrowed(SNAP_SHOT_SVG.as_bytes()))); + } if path == STAGE_DEV { return Ok(Some(Cow::Borrowed(include_bytes!("stage-dev.svg")))); } @@ -297,6 +328,9 @@ impl AssetSource for AppAssets { if MESSAGE_SQUARE_DASHED.starts_with(path) { paths.push(MESSAGE_SQUARE_DASHED.into()); } + if SNAP_SHOT.starts_with(path) { + paths.push(SNAP_SHOT.into()); + } paths.extend(ExtraIcons.list(path)?); Ok(paths) } @@ -320,6 +354,23 @@ mod tests { fn every_catalog_icon_the_app_uses_loads() { for icon in [ IconName::Paperclip, + IconName::Settings2, + IconName::Palette, + IconName::MessageSquareText, + IconName::MessagesSquare, + IconName::Keyboard, + IconName::Bot, + IconName::Globe, + IconName::Smartphone, + IconName::CalendarClock, + IconName::FolderTree, + IconName::ShieldCheck, + IconName::KeyRound, + IconName::Wrench, + IconName::FlaskConical, + IconName::Archive, + IconName::PanelsTopLeft, + IconName::Undo2, IconName::File, IconName::SquareMenu, IconName::PanelBottom, @@ -384,6 +435,7 @@ mod tests { .flatten() .is_some() ); + assert!(AppAssets.load(SNAP_SHOT).ok().flatten().is_some()); } #[test] diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 1b67bf93d12b..96e0846fa271 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -21,6 +21,7 @@ mod refs; mod rpc; mod send; mod server; +mod settings; mod shell; mod slow_requests; mod status; @@ -108,9 +109,12 @@ fn main() { Theme::change(ThemeMode::Dark, None, cx); // The web's default `sidebar.toggle` and `navigation.*` bindings. cx.bind_keys(ui::usage_bindings()); + cx.bind_keys(ui::settings_bindings()); cx.bind_keys([ KeyBinding::new("cmd-b", ui::ToggleSidebar, None), KeyBinding::new("cmd-u", ui::OpenUsage, None), + // `CmdOrCtrl+,` of the desktop application menu (DesktopApplicationMenu.ts:174). + KeyBinding::new("cmd-,", ui::OpenSettings, None), KeyBinding::new("cmd-u", gpui::NoAction, Some("Terminal")), KeyBinding::new("escape", ui::DismissSidebar, Some(ui::OVERLAY_CONTEXT)), KeyBinding::new("shift-cmd-a", ui::OpenPermissions, None), diff --git a/apps/desktop-gpui/src/settings.rs b/apps/desktop-gpui/src/settings.rs new file mode 100644 index 000000000000..d53cffd52cd6 --- /dev/null +++ b/apps/desktop-gpui/src/settings.rs @@ -0,0 +1,703 @@ +//! Device settings (`ClientSettingsSchema`, contracts/src/settings.ts:353) behind the Settings +//! route's General tab, and the route's navigation and search model. +//! +//! The web persists these through `DesktopClientSettings.ts` as `client-settings.json`; this +//! store keeps that file name and its camelCase keys, so the two stay readable by each other. + +use serde_json::{Map, Value, json}; +use std::path::{Path, PathBuf}; + +const FILE: &str = "client-settings.json"; + +macro_rules! choice { + ($(#[$meta:meta])* $name:ident { $($variant:ident => ($wire:literal, $label:literal)),+ $(,)? }) => { + $(#[$meta])* + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub enum $name { + $($variant),+ + } + + impl $name { + pub const ALL: &'static [Self] = &[$(Self::$variant),+]; + + pub fn wire(self) -> &'static str { + match self { + $(Self::$variant => $wire),+ + } + } + + pub fn label(self) -> &'static str { + match self { + $(Self::$variant => $label),+ + } + } + + pub fn from_wire(value: &str) -> Option { + match value { + $($wire => Some(Self::$variant),)+ + _ => None, + } + } + } + }; +} + +choice! { + /// `ThreadNotificationMode`; labels from `threadNotifications.ts:8-13`. + NotificationMode { + Off => ("off", "Off"), + Notifications => ("notifications", "Notifications only"), + Sound => ("sound", "Sound only"), + NotificationsAndSound => ("notifications-and-sound", "Notifications with sound"), + } +} + +/// `SidebarProjectGroupingMode`; the page shows it as an on/off switch. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum GroupingMode { + Repository, + RepositoryPath, + Separate, +} + +impl GroupingMode { + pub fn wire(self) -> &'static str { + match self { + Self::Repository => "repository", + Self::RepositoryPath => "repository_path", + Self::Separate => "separate", + } + } + + pub fn from_wire(value: &str) -> Option { + match value { + "repository" => Some(Self::Repository), + "repository_path" => Some(Self::RepositoryPath), + "separate" => Some(Self::Separate), + _ => None, + } + } +} + +choice! { + /// `SidebarProjectSortOrder`; labels from `SettingsPanels.tsx` `SIDEBAR_PROJECT_SORT_LABELS`. + SortOrder { + UpdatedAt => ("updated_at", "Last user message"), + CreatedAt => ("created_at", "Created at"), + Manual => ("manual", "Manual"), + } +} + +choice! { + /// `ConfirmQuitMode`; labels from `SettingsPanels.tsx` `CONFIRM_QUIT_LABELS`. + QuitMode { + Direct => ("direct", "Direct"), + Hold => ("hold", "Hold"), + DoubleClick => ("double-click", "Double press"), + } +} + +choice! { + /// `TimestampFormat`; labels from `SettingsPanels.tsx` `TIMESTAMP_FORMAT_LABELS`. + TimeFormat { + Locale => ("locale", "System default"), + Hour12 => ("12-hour", "12-hour"), + Hour24 => ("24-hour", "24-hour"), + } +} + +/// The General tab's slice of the client settings, with `DEFAULT_CLIENT_SETTINGS` defaults. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DeviceSettings { + pub notification_mode: NotificationMode, + pub in_app_notifications: bool, + pub project_grouping: GroupingMode, + pub project_order: SortOrder, + pub working_shelf: bool, + pub confirm_unpin: bool, + pub confirm_archive: bool, + pub confirm_delete: bool, + pub confirm_quit: QuitMode, + pub time_format: TimeFormat, +} + +impl Default for DeviceSettings { + fn default() -> Self { + Self { + notification_mode: NotificationMode::Off, + in_app_notifications: false, + project_grouping: GroupingMode::Repository, + project_order: SortOrder::UpdatedAt, + working_shelf: true, + confirm_unpin: false, + confirm_archive: false, + confirm_delete: true, + confirm_quit: QuitMode::Hold, + time_format: TimeFormat::Locale, + } + } +} + +/// The document is bare or wrapped as `{"settings": {...}}` (`decodeClientSettingsJson`). +fn fields(value: &Value) -> Option<&Map> { + let object = value.as_object()?; + match object.get("settings") { + Some(Value::Object(inner)) => Some(inner), + _ => Some(object), + } +} + +impl DeviceSettings { + /// A missing or invalid key falls back to its default; anything but an object is rejected. + pub fn parse(text: &str) -> Option { + let value: Value = serde_json::from_str(text).ok()?; + let map = fields(&value)?; + let defaults = Self::default(); + let flag = + |key: &str, fallback: bool| map.get(key).and_then(Value::as_bool).unwrap_or(fallback); + let choice = |key: &str| map.get(key).and_then(Value::as_str); + Some(Self { + notification_mode: choice("notificationMode") + .and_then(NotificationMode::from_wire) + .unwrap_or(defaults.notification_mode), + in_app_notifications: flag("inAppNotificationsEnabled", defaults.in_app_notifications), + project_grouping: choice("sidebarProjectGroupingMode") + .and_then(GroupingMode::from_wire) + .unwrap_or(defaults.project_grouping), + project_order: choice("sidebarProjectSortOrder") + .and_then(SortOrder::from_wire) + .unwrap_or(defaults.project_order), + working_shelf: flag("sidebarWorkingShelfEnabled", defaults.working_shelf), + confirm_unpin: flag("confirmThreadUnpin", defaults.confirm_unpin), + confirm_archive: flag("confirmThreadArchive", defaults.confirm_archive), + confirm_delete: flag("confirmThreadDelete", defaults.confirm_delete), + // A legacy boolean decodes `true` to hold and `false` to direct. + confirm_quit: match map.get("confirmQuit") { + Some(Value::Bool(true)) => QuitMode::Hold, + Some(Value::Bool(false)) => QuitMode::Direct, + Some(Value::String(mode)) => { + QuitMode::from_wire(mode).unwrap_or(defaults.confirm_quit) + } + _ => defaults.confirm_quit, + }, + time_format: choice("timestampFormat") + .and_then(TimeFormat::from_wire) + .unwrap_or(defaults.time_format), + }) + } + + pub fn entries(self) -> [(&'static str, Value); 10] { + [ + ("notificationMode", json!(self.notification_mode.wire())), + ( + "inAppNotificationsEnabled", + json!(self.in_app_notifications), + ), + ( + "sidebarProjectGroupingMode", + json!(self.project_grouping.wire()), + ), + ("sidebarProjectSortOrder", json!(self.project_order.wire())), + ("sidebarWorkingShelfEnabled", json!(self.working_shelf)), + ("confirmThreadUnpin", json!(self.confirm_unpin)), + ("confirmThreadArchive", json!(self.confirm_archive)), + ("confirmThreadDelete", json!(self.confirm_delete)), + ("confirmQuit", json!(self.confirm_quit.wire())), + ("timestampFormat", json!(self.time_format.wire())), + ] + } + + /// Writes these keys into `existing`, keeping every key this tab does not own. + pub fn to_json(self, existing: Option<&str>) -> String { + let mut document = existing + .and_then(|text| serde_json::from_str::(text).ok()) + .filter(Value::is_object) + .unwrap_or_else(|| json!({})); + let target = match document.get_mut("settings") { + Some(inner) if inner.is_object() => inner, + _ => &mut document, + }; + if let Some(map) = target.as_object_mut() { + for (key, value) in self.entries() { + map.insert(key.to_owned(), value); + } + } + document.to_string() + } + + pub fn path() -> Option { + let home = std::env::var(crate::config::ENV_HOME).ok()?; + (!home.trim().is_empty()).then(|| Path::new(&home).join(FILE)) + } + + pub fn load() -> Self { + Self::path() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|text| Self::parse(&text)) + .unwrap_or_default() + } + + pub fn save(self) { + if let Some(path) = Self::path() { + let existing = std::fs::read_to_string(&path).ok(); + let _ = std::fs::write(path, self.to_json(existing.as_deref())); + } + } + + /// `useSettingsRestore`'s labels, in its order, for the values that differ from the defaults. + pub fn changed_labels(self) -> Vec<&'static str> { + let d = Self::default(); + [ + (self.time_format != d.time_format, "Time format"), + ( + self.notification_mode != d.notification_mode, + "Thread notifications", + ), + ( + self.in_app_notifications != d.in_app_notifications, + "In-app notifications", + ), + ( + self.project_grouping != d.project_grouping, + "Project Grouping", + ), + (self.project_order != d.project_order, "Project order"), + (self.working_shelf != d.working_shelf, "Working section"), + (self.confirm_unpin != d.confirm_unpin, "Unpin confirmation"), + ( + self.confirm_archive != d.confirm_archive, + "Archive confirmation", + ), + ( + self.confirm_delete != d.confirm_delete, + "Delete confirmation", + ), + (self.confirm_quit != d.confirm_quit, "Quit shortcut"), + ] + .into_iter() + .filter_map(|(changed, label)| changed.then_some(label)) + .collect() + } +} + +/// The grouping switch is on for any mode but `separate`; turning it on again restores the last +/// enabled mode (`SettingsPanels.logic.ts:21-50`). +pub fn toggle_grouping(current: GroupingMode, last_enabled: GroupingMode) -> GroupingMode { + if current == GroupingMode::Separate { + match last_enabled { + GroupingMode::RepositoryPath => GroupingMode::RepositoryPath, + _ => GroupingMode::Repository, + } + } else { + GroupingMode::Separate + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Section { + General, + Appearance, + Chat, + Diffs, + Keybindings, + SnapShot, + Providers, + Threads, + Browser, + Devices, + ScheduledTasks, + Projects, + SourceControl, + Worktrees, + Connections, + Access, + Secrets, + Maintenance, + Labs, + About, + Archived, +} + +impl Section { + /// `SETTINGS_SECTION_LABELS` (settingsSearch.ts). + pub fn label(self) -> &'static str { + match self { + Self::General => "General", + Self::Appearance => "Appearance", + Self::Chat => "Chat", + Self::Diffs => "Diffs & pull requests", + Self::Keybindings => "Keybindings", + Self::SnapShot => "SnapShots", + Self::Providers => "Providers", + Self::Threads => "Threads", + Self::Browser => "Browser", + Self::Devices => "Devices", + Self::ScheduledTasks => "Scheduled tasks", + Self::Projects => "Projects", + Self::SourceControl => "Source control", + Self::Worktrees => "Worktrees", + Self::Connections => "Environments", + Self::Access => "Sharing & access", + Self::Secrets => "Secrets", + Self::Maintenance => "Maintenance", + Self::Labs => "Labs", + Self::About => "About", + Self::Archived => "Archive", + } + } +} + +pub struct NavGroup { + pub heading: Option<&'static str>, + pub sections: &'static [Section], +} + +/// `SETTINGS_NAV_GROUPS` (settingsSearch.ts:116-153), with `isSecretsFeatureEnabled` on. +pub const NAV_GROUPS: &[NavGroup] = &[ + NavGroup { + heading: None, + sections: &[ + Section::General, + Section::Appearance, + Section::Chat, + Section::Diffs, + Section::Keybindings, + Section::SnapShot, + ], + }, + NavGroup { + heading: Some("Agents"), + sections: &[ + Section::Providers, + Section::Threads, + Section::Browser, + Section::Devices, + Section::ScheduledTasks, + ], + }, + NavGroup { + heading: Some("Code"), + sections: &[ + Section::Projects, + Section::SourceControl, + Section::Worktrees, + ], + }, + NavGroup { + heading: Some("Connections"), + sections: &[Section::Connections, Section::Access, Section::Secrets], + }, + NavGroup { + heading: None, + sections: &[ + Section::Maintenance, + Section::Labs, + Section::About, + Section::Archived, + ], + }, +]; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SearchItem { + pub id: &'static str, + pub title: &'static str, + pub section: Section, + pub terms: &'static str, + /// Shown only in the desktop app (`desktopOnly`). + pub desktop_only: bool, +} + +const fn item( + id: &'static str, + title: &'static str, + terms: &'static str, + desktop_only: bool, +) -> SearchItem { + SearchItem { + id, + title, + section: Section::General, + terms, + desktop_only, + } +} + +/// The General tab's `searchableSetting` entries, in the web's order. The other tabs have no +/// panel here yet, so their settings are not searchable. +pub const GENERAL_ITEMS: &[SearchItem] = &[ + item( + "project-grouping", + "Project grouping", + "combine matching repositories environments sidebar", + false, + ), + item( + "project-order", + "Project order", + "sort projects sidebar manual created recent", + false, + ), + item( + "working-shelf", + "Working section", + "hide fold running monitoring threads inbox sidebar shelf", + false, + ), + item( + "thread-notifications", + "Thread notifications", + "notification sound alert completion input approval desktop", + false, + ), + item( + "in-app-notifications", + "In-app notifications", + "notification toast popup completion input approval failure", + false, + ), + item( + "time-format", + "Time format", + "timestamp clock locale system browser os 12 hour 24 hour", + false, + ), + item( + "unpin-confirmation", + "Unpin confirmation", + "ask before thread pinned section", + false, + ), + item( + "archive-confirmation", + "Archive confirmation", + "ask before thread second click inline action", + false, + ), + item( + "delete-confirmation", + "Delete confirmation", + "ask before thread chat history", + false, + ), + item( + "quit-confirmation", + "Quit shortcut", + "confirmation desktop app exit direct hold double click press twice", + true, + ), +]; + +/// `normalizeSearchText`: NFKD, marks stripped, lowercased, whitespace collapsed. +pub fn normalize(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for ch in text.chars().flat_map(decompose) { + if is_combining(ch) { + continue; + } + out.extend(ch.to_lowercase()); + } + out.split_whitespace().collect::>().join(" ") +} + +/// Latin letters with a common accent, without pulling in a Unicode normalization crate. +fn decompose(ch: char) -> impl Iterator { + let base = match ch { + 'à'..='å' => 'a', + 'À'..='Å' => 'A', + 'ç' => 'c', + 'Ç' => 'C', + 'è'..='ë' => 'e', + 'È'..='Ë' => 'E', + 'ì'..='ï' => 'i', + 'Ì'..='Ï' => 'I', + 'ñ' => 'n', + 'Ñ' => 'N', + 'ò'..='ö' => 'o', + 'Ò'..='Ö' => 'O', + 'ù'..='ü' => 'u', + 'Ù'..='Ü' => 'U', + 'ý' | 'ÿ' => 'y', + other => other, + }; + std::iter::once(base) +} + +fn is_combining(ch: char) -> bool { + matches!(ch, '\u{0300}'..='\u{036f}') +} + +/// `searchSettings`: every token must appear in the title, the section label or the terms; +/// ranked title-exact, title-prefix, title-substring, all-tokens-in-title, phrase anywhere. +pub fn search<'a>(query: &str, items: &'a [SearchItem]) -> Vec<&'a SearchItem> { + let query = normalize(query); + if query.is_empty() { + return Vec::new(); + } + let tokens: Vec<&str> = query.split(' ').collect(); + let mut hits: Vec<(u8, usize, &SearchItem)> = items + .iter() + .enumerate() + .filter_map(|(index, item)| { + let title = normalize(item.title); + let fields = [ + title.clone(), + normalize(item.section.label()), + normalize(item.terms), + ]; + if !tokens + .iter() + .all(|token| fields.iter().any(|field| field.contains(token))) + { + return None; + } + let rank = if title == query { + 5 + } else if title.starts_with(&query) { + 4 + } else if title.contains(&query) { + 3 + } else if tokens.iter().all(|token| title.contains(token)) { + 2 + } else if fields.iter().any(|field| field.contains(&query)) { + 1 + } else { + 0 + }; + Some((rank, index, item)) + }) + .collect(); + hits.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(&b.1))); + hits.into_iter().map(|(_, _, item)| item).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_the_contract() { + let d = DeviceSettings::default(); + assert_eq!(d.notification_mode, NotificationMode::Off); + assert!(!d.in_app_notifications); + assert_eq!(d.project_grouping, GroupingMode::Repository); + assert_eq!(d.project_order, SortOrder::UpdatedAt); + assert!(d.working_shelf); + assert!(!d.confirm_unpin && !d.confirm_archive && d.confirm_delete); + assert_eq!(d.confirm_quit, QuitMode::Hold); + assert_eq!(d.time_format, TimeFormat::Locale); + assert!(d.changed_labels().is_empty()); + } + + #[test] + fn round_trips_every_field() { + let changed = DeviceSettings { + notification_mode: NotificationMode::NotificationsAndSound, + in_app_notifications: true, + project_grouping: GroupingMode::Separate, + project_order: SortOrder::Manual, + working_shelf: false, + confirm_unpin: true, + confirm_archive: true, + confirm_delete: false, + confirm_quit: QuitMode::DoubleClick, + time_format: TimeFormat::Hour24, + }; + assert_eq!(DeviceSettings::parse(&changed.to_json(None)), Some(changed)); + } + + #[test] + fn missing_and_invalid_keys_take_defaults() { + let parsed = DeviceSettings::parse( + r#"{"notificationMode":"loud","confirmThreadDelete":false,"timestampFormat":"12-hour"}"#, + ) + .unwrap(); + assert_eq!(parsed.notification_mode, NotificationMode::Off); + assert!(!parsed.confirm_delete); + assert_eq!(parsed.time_format, TimeFormat::Hour12); + assert_eq!(DeviceSettings::parse("[]"), None); + assert_eq!(DeviceSettings::parse("nope"), None); + } + + #[test] + fn reads_the_wrapped_document_and_the_legacy_quit_boolean() { + let wrapped = DeviceSettings::parse(r#"{"settings":{"confirmQuit":false}}"#).unwrap(); + assert_eq!(wrapped.confirm_quit, QuitMode::Direct); + let legacy = DeviceSettings::parse(r#"{"confirmQuit":true}"#).unwrap(); + assert_eq!(legacy.confirm_quit, QuitMode::Hold); + } + + #[test] + fn writing_keeps_keys_it_does_not_own() { + let existing = r#"{"settings":{"theme":"dark","confirmThreadDelete":false}}"#; + let written = DeviceSettings::default().to_json(Some(existing)); + let value: Value = serde_json::from_str(&written).unwrap(); + assert_eq!(value["settings"]["theme"], "dark"); + assert_eq!(value["settings"]["confirmThreadDelete"], true); + let bare = DeviceSettings::default().to_json(Some(r#"{"theme":"dark"}"#)); + assert!(bare.contains("\"theme\":\"dark\"")); + } + + #[test] + fn changed_labels_follow_the_restore_order_and_restore_clears_them() { + let mut settings = DeviceSettings { + confirm_quit: QuitMode::Direct, + time_format: TimeFormat::Hour12, + working_shelf: false, + ..DeviceSettings::default() + }; + assert_eq!( + settings.changed_labels(), + ["Time format", "Working section", "Quit shortcut"] + ); + settings = DeviceSettings::default(); + assert!(settings.changed_labels().is_empty()); + } + + #[test] + fn grouping_toggle_remembers_the_last_enabled_mode() { + use GroupingMode::*; + assert_eq!(toggle_grouping(Repository, Repository), Separate); + assert_eq!(toggle_grouping(RepositoryPath, Repository), Separate); + assert_eq!(toggle_grouping(Separate, RepositoryPath), RepositoryPath); + assert_eq!(toggle_grouping(Separate, Repository), Repository); + assert_eq!(toggle_grouping(Separate, Separate), Repository); + } + + #[test] + fn nav_lists_every_section_once_in_the_web_order() { + let all: Vec
= NAV_GROUPS + .iter() + .flat_map(|group| group.sections.iter().copied()) + .collect(); + assert_eq!(all.len(), 21); + assert_eq!(all[0], Section::General); + assert_eq!(all[all.len() - 1], Section::Archived); + let mut unique = all.clone(); + unique.sort_by_key(|section| section.label()); + unique.dedup(); + assert_eq!(unique.len(), 21); + } + + #[test] + fn search_normalizes_ranks_and_requires_every_token() { + assert!(search("", GENERAL_ITEMS).is_empty()); + assert!(search("zzz", GENERAL_ITEMS).is_empty()); + let ids = |query: &str| -> Vec<&str> { + search(query, GENERAL_ITEMS).iter().map(|i| i.id).collect() + }; + assert_eq!(ids(" TIME Format ")[0], "time-format"); + assert_eq!(ids("project")[..2], ["project-grouping", "project-order"]); + // "ask" matches only through terms, and keeps the original order. + assert_eq!( + ids("ask before"), + [ + "unpin-confirmation", + "archive-confirmation", + "delete-confirmation" + ] + ); + assert_eq!(ids("sidebar manual"), ["project-order"]); + assert_eq!(normalize("Café Ñandú"), "cafe nandu"); + } +} diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 8a0368e7e417..c4129dee7115 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -57,8 +57,10 @@ const TERMINAL_SIDEBAR_WIDTH: f32 = 144.; const TERMINAL_HANDLE_HEIGHT: f32 = 8.; mod model_popover; mod right_panel; +mod settings; mod usage_dialog; +pub use settings::bindings as settings_bindings; pub use usage_dialog::bindings as usage_bindings; /// The web's `THREAD_SIDEBAR_DEFAULT_WIDTH`. @@ -96,7 +98,8 @@ actions!( OpenFavoriteEditor, NewThread, NewScratchThread, - OpenUsage + OpenUsage, + OpenSettings ] ); @@ -274,6 +277,7 @@ pub struct MainView { card: Entity, toasts: Entity, usage: Entity, + settings: Entity, /// The slow requests toast; kept after the user dismisses it, like the web coordinator. slow_toast: Option, /// The main area's width at the last render, for fitting the details card. @@ -440,6 +444,7 @@ impl MainView { card, toasts: cx.new(|cx| ToastHost::new(TOPBAR_HEIGHT, window, cx)), usage: cx.new(|cx| usage_dialog::UsageDialog::new(actions.clone(), cx)), + settings: cx.new(|cx| settings::SettingsDialog::new(window, cx)), slow_toast: None, main_width: Cell::new(0.), sending: None, @@ -2308,12 +2313,14 @@ impl MainView { h_flex() .pt_1() .gap_px() - .child(footer_button( - "settings", - Icon::new(IconName::Settings), - "Settings", - cx, - )) + .child( + footer_button("settings", Icon::new(IconName::Settings), "Settings", cx).on_click( + cx.listener(|this, _, window, cx| { + this.settings + .update(cx, |settings, cx| settings.open(window, cx)); + }), + ), + ) .child(footer_button( "pull-requests", Icon::new(Icons::GitPullRequest), @@ -6139,6 +6146,10 @@ impl Render for MainView { .on_action(cx.listener(|this, _: &OpenUsage, window, cx| { this.usage.update(cx, |usage, cx| usage.open(window, cx)); })) + .on_action(cx.listener(|this, _: &OpenSettings, window, cx| { + this.settings + .update(cx, |settings, cx| settings.open(window, cx)); + })) .on_action(cx.listener(|this, _: &ToggleRightPanel, _, cx| this.toggle_right_panel(cx))) .on_action( cx.listener(|this, _: &ToggleTerminal, window, cx| { @@ -6190,6 +6201,7 @@ impl Render for MainView { root.child(self.sidebar_drawer(overlay_width(width), window, cx)) }) .child(self.usage.clone()) + .child(self.settings.clone()) .child(self.toasts.clone()) } } diff --git a/apps/desktop-gpui/src/ui/settings.rs b/apps/desktop-gpui/src/ui/settings.rs new file mode 100644 index 000000000000..03df508f54cf --- /dev/null +++ b/apps/desktop-gpui/src/ui/settings.rs @@ -0,0 +1,1223 @@ +//! The web's Settings route (`_chat.settings.tsx`) as an `AppRouteModal`, with its section +//! navigation (`SettingsSidebarNav.tsx`) and the General tab (`GeneralSettingsPanel`). + +mod components; + +use super::*; +use crate::assets::SNAP_SHOT; +use crate::settings::{ + self as model, DeviceSettings, GENERAL_ITEMS, GroupingMode, NAV_GROUPS, NotificationMode, + QuitMode, SearchItem, Section, SortOrder, TimeFormat, +}; +use components::{ + HoverHandler, KeyHandler, Menu, PickHandler, SelectTrigger, group, reset_button, row, section, + select_menu, switch, +}; +use gpui::{ + ClickEvent, FocusHandle, KeyBinding, KeyDownEvent, ScrollHandle, Subscription, + transparent_black, +}; +use std::rc::Rc; + +const CONTEXT: &str = "SettingsPage"; +/// `SettingsSidebarNav`'s `w-60`. +const NAV_WIDTH: f32 = 240.; +/// `SettingsRow` switches to two columns from a `32rem` container. +const ROW_WIDE: f32 = 512.; +/// `WorkspacePageContainer` `max-w-4xl`. +const CONTENT_MAX: f32 = 896.; + +actions!(settings, [CloseSettings, FocusSettingsSearch]); + +/// Escape closes the modal; `cmd-f` is the nav's "focus search" shortcut. +pub fn bindings() -> Vec { + vec![ + KeyBinding::new("escape", CloseSettings, Some(CONTEXT)), + KeyBinding::new("cmd-f", FocusSettingsSearch, Some(CONTEXT)), + ] +} + +/// The four selects of the General tab. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Pick { + Notifications, + ProjectOrder, + Quit, + Time, +} + +impl Pick { + fn id(self) -> &'static str { + match self { + Self::Notifications => "settings-thread-notifications", + Self::ProjectOrder => "settings-project-order", + Self::Quit => "settings-quit-shortcut", + Self::Time => "settings-time-format", + } + } + + /// `w-full sm:w-56`, `sm:w-44`, `sm:w-40`, `sm:w-40`. + fn width(self) -> f32 { + match self { + Self::Notifications => 224., + Self::ProjectOrder => 176., + Self::Quit | Self::Time => 160., + } + } + + fn labels(self) -> Vec<&'static str> { + match self { + Self::Notifications => NotificationMode::ALL.iter().map(|m| m.label()).collect(), + Self::ProjectOrder => SortOrder::ALL.iter().map(|m| m.label()).collect(), + Self::Quit => QuitMode::ALL.iter().map(|m| m.label()).collect(), + Self::Time => TimeFormat::ALL.iter().map(|m| m.label()).collect(), + } + } + + fn selected(self, settings: &DeviceSettings) -> usize { + let index = match self { + Self::Notifications => NotificationMode::ALL + .iter() + .position(|m| *m == settings.notification_mode), + Self::ProjectOrder => SortOrder::ALL + .iter() + .position(|m| *m == settings.project_order), + Self::Quit => QuitMode::ALL + .iter() + .position(|m| *m == settings.confirm_quit), + Self::Time => TimeFormat::ALL + .iter() + .position(|m| *m == settings.time_format), + }; + index.unwrap_or(0) + } + + fn apply(self, settings: &mut DeviceSettings, index: usize) { + match self { + Self::Notifications => settings.notification_mode = NotificationMode::ALL[index], + Self::ProjectOrder => settings.project_order = SortOrder::ALL[index], + Self::Quit => settings.confirm_quit = QuitMode::ALL[index], + Self::Time => settings.time_format = TimeFormat::ALL[index], + } + } + + const ALL: [Self; 4] = [ + Self::Notifications, + Self::ProjectOrder, + Self::Quit, + Self::Time, + ]; +} + +pub struct SettingsDialog { + focus: FocusHandle, + previous_focus: Option, + open: bool, + settings: DeviceSettings, + section: Section, + last_grouping: GroupingMode, + search: Entity, + active_result: usize, + /// "/" moved focus to the search; its character must not land in the field. + swallow_slash: bool, + menu: Option<(Pick, usize)>, + triggers: [FocusHandle; 4], + confirm_restore: bool, + scroll: ScrollHandle, + _search_events: Subscription, +} + +impl SettingsDialog { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search")); + let events = cx.subscribe_in( + &search, + window, + |this, _, event: &InputEvent, window, cx| match event { + InputEvent::Change => this.search_changed(window, cx), + InputEvent::PressEnter { .. } => this.activate_result(window, cx), + _ => {} + }, + ); + Self { + focus: cx.focus_handle(), + previous_focus: None, + open: false, + settings: DeviceSettings::load(), + section: Section::General, + last_grouping: GroupingMode::Repository, + search, + active_result: 0, + swallow_slash: false, + menu: None, + triggers: std::array::from_fn(|_| cx.focus_handle().tab_stop(true)), + confirm_restore: false, + scroll: ScrollHandle::new(), + _search_events: events, + } + } + + pub fn open(&mut self, window: &mut Window, cx: &mut Context) { + if self.open { + return; + } + self.open = true; + self.previous_focus = window.focused(cx); + self.settings = DeviceSettings::load(); + self.menu = None; + self.confirm_restore = false; + window.focus(&self.focus, cx); + cx.notify(); + } + + pub fn close(&mut self, window: &mut Window, cx: &mut Context) { + if !self.open { + return; + } + self.open = false; + self.clear_search(window, cx); + if let Some(previous) = self.previous_focus.take() { + window.focus(&previous, cx); + } + cx.notify(); + } + + fn query(&self, cx: &App) -> String { + self.search.read(cx).value().to_string() + } + + fn results(&self, cx: &App) -> Vec<&'static SearchItem> { + let desktop = true; + let query = self.query(cx); + model::search(&query, GENERAL_ITEMS) + .into_iter() + .filter(|item| desktop || !item.desktop_only) + .collect() + } + + fn search_changed(&mut self, window: &mut Window, cx: &mut Context) { + if std::mem::take(&mut self.swallow_slash) && self.query(cx) == "/" { + self.search + .update(cx, |input, cx| input.set_value("", window, cx)); + } + self.active_result = 0; + cx.notify(); + } + + fn clear_search(&mut self, window: &mut Window, cx: &mut Context) { + self.search + .update(cx, |input, cx| input.set_value("", window, cx)); + self.active_result = 0; + } + + fn focus_search(&mut self, window: &mut Window, cx: &mut Context) { + let handle = self.search.focus_handle(cx); + window.focus(&handle, cx); + } + + fn activate_result(&mut self, window: &mut Window, cx: &mut Context) { + let results = self.results(cx); + if let Some(item) = results.get(self.active_result) { + self.section = item.section; + self.clear_search(window, cx); + window.focus(&self.focus, cx); + cx.notify(); + } + } + + fn select_section(&mut self, section: Section, cx: &mut Context) { + self.section = section; + self.scroll.set_offset(gpui::point(px(0.), px(0.))); + cx.notify(); + } + + fn escape(&mut self, window: &mut Window, cx: &mut Context) { + if self.confirm_restore { + self.confirm_restore = false; + } else if self.menu.is_some() { + self.close_menu(window, cx); + return; + } else if self.search.focus_handle(cx).is_focused(window) && !self.query(cx).is_empty() { + self.clear_search(window, cx); + } else { + self.close(window, cx); + return; + } + cx.notify(); + } + + fn open_menu(&mut self, pick: Pick, cx: &mut Context) { + self.menu = Some((pick, pick.selected(&self.settings))); + cx.notify(); + } + + fn close_menu(&mut self, window: &mut Window, cx: &mut Context) { + if let Some((pick, _)) = self.menu.take() { + let index = Pick::ALL.iter().position(|p| *p == pick).unwrap_or(0); + window.focus(&self.triggers[index], cx); + cx.notify(); + } + } + + fn pick(&mut self, pick: Pick, index: usize, window: &mut Window, cx: &mut Context) { + self.update(|settings| pick.apply(settings, index), cx); + self.close_menu(window, cx); + } + + fn update(&mut self, change: impl FnOnce(&mut DeviceSettings), cx: &mut Context) { + change(&mut self.settings); + self.settings.save(); + cx.notify(); + } + + fn restore_defaults(&mut self, cx: &mut Context) { + self.confirm_restore = false; + self.last_grouping = GroupingMode::Repository; + self.update(|settings| *settings = DeviceSettings::default(), cx); + } + + fn toggle_grouping(&mut self, cx: &mut Context) { + let current = self.settings.project_grouping; + if current != GroupingMode::Separate { + self.last_grouping = current; + } + let next = model::toggle_grouping(current, self.last_grouping); + self.update(|settings| settings.project_grouping = next, cx); + } + + fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { + let key = event.keystroke.key.as_str(); + if let Some((pick, highlighted)) = self.menu { + let count = pick.labels().len(); + match key { + "down" => self.menu = Some((pick, (highlighted + 1) % count)), + "up" => self.menu = Some((pick, (highlighted + count - 1) % count)), + "home" => self.menu = Some((pick, 0)), + "end" => self.menu = Some((pick, count - 1)), + "enter" | "space" => self.pick(pick, highlighted, window, cx), + _ => return, + } + cx.stop_propagation(); + cx.notify(); + return; + } + let searching = !self.query(cx).is_empty(); + let search_focused = self.search.focus_handle(cx).is_focused(window); + if key == "/" && !event.keystroke.modifiers.modified() && !search_focused { + self.swallow_slash = true; + self.focus_search(window, cx); + cx.stop_propagation(); + return; + } + if !searching { + return; + } + let count = self.results(cx).len(); + if count == 0 { + return; + } + match key { + "down" => self.active_result = (self.active_result + 1) % count, + "up" => self.active_result = (self.active_result + count - 1) % count, + _ => return, + } + cx.stop_propagation(); + cx.notify(); + } +} + +impl Render for SettingsDialog { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + if !self.open { + return gpui::Empty.into_any_element(); + } + let theme = cx.theme(); + let viewport = window.viewport_size(); + let (vw, vh) = (f32::from(viewport.width), f32::from(viewport.height)); + let pad = if vw >= 1024. { + 40. + } else if vw >= 640. { + 24. + } else { + 0. + }; + let framed = vw >= 640.; + let (width, height) = if framed { + ((vw - 2. * pad).min(1280.), (vh - 2. * pad).min(960.)) + } else { + (vw, vh) + }; + let backdrop = theme + .background + .opacity(if theme.is_dark() { 0.64 } else { 0.6 }); + let (border, background) = (theme.border, theme.background); + let nav = self.nav(window, cx); + let header = self.header(cx); + let body = self.body(width - NAV_WIDTH, window, cx); + let confirm = self.restore_dialog(cx); + div() + .absolute() + .inset_0() + .key_context(CONTEXT) + .track_focus(&self.focus) + .on_action(cx.listener(|this, _: &CloseSettings, window, cx| this.escape(window, cx))) + .on_action(cx.listener(|this, _: &FocusSettingsSearch, window, cx| { + this.focus_search(window, cx) + })) + .on_key_down(cx.listener(|this, event, window, cx| this.key_down(event, window, cx))) + .child( + div() + .id("settings-backdrop") + .absolute() + .inset_0() + .occlude() + .bg(backdrop) + .on_click(cx.listener(|this, _, window, cx| this.close(window, cx))), + ) + .child( + v_flex() + .absolute() + .left(px((vw - width) / 2.)) + .top(px((vh - height) / 2.)) + .w(px(width)) + .h(px(height)) + .overflow_hidden() + .occlude() + .bg(background) + .text_color(cx.theme().foreground) + .when(framed, |popup| { + popup + .rounded(px(16.)) + .border_1() + .border_color(border) + .shadow_2xl() + }) + .child( + h_flex().relative().flex_1().min_h_0().child(nav).child( + v_flex().flex_1().min_w_0().h_full().child(header).child( + div() + .id("settings-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .track_scroll(&self.scroll) + .child(body), + ), + ), + ), + ) + .children(confirm) + .into_any_element() + } +} + +impl SettingsDialog { + /// `SettingsContentLayout`'s header: `h-12 gap-3 border-b ps-5 pe-2`. + fn header(&self, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let (muted, border, foreground, hover) = ( + theme.muted_foreground, + theme.border, + theme.foreground, + theme.accent, + ); + let general = self.section == Section::General; + let disabled = self.settings.changed_labels().is_empty(); + let crumbs = h_flex() + .min_w_0() + .items_center() + .gap_3() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(div().flex_shrink_0().text_color(muted).child("Settings")) + .child(div().flex_shrink_0().text_color(muted).child("/")) + .child( + div() + .min_w_0() + .truncate() + .text_color(foreground) + .child(self.section.label()), + ); + // `size="xs" variant="ghost"`: `h-6 gap-1 px-[7px] text-xs`, icon `mx-1 size-3.5`. + let restore = h_flex() + .id("settings-restore") + .h(px(24.)) + .items_center() + .gap_1() + .px(px(7.)) + .rounded(px(8.)) + .border_1() + .border_color(transparent_black()) + .text_xs() + .text_color(foreground) + .when(disabled, |button| button.opacity(0.64)) + .when(!disabled, |button| { + button + .cursor_pointer() + .hover(move |style| style.bg(hover)) + .on_click(cx.listener(|this, _, _, cx| { + this.confirm_restore = true; + cx.notify(); + })) + }) + .child( + div() + .mx_1() + .text_color(muted) + .child(Icon::new(Icons::RotateCcw).size(px(14.))), + ) + .child("Restore device defaults"); + let close = ghost_icon_button( + "settings-close", + Icon::new(IconName::Close).size(px(14.)), + "Close (Esc)".into(), + true, + cx, + ) + .on_click(cx.listener(|this, _, window, cx| this.close(window, cx))); + h_flex() + .h(px(48.)) + .flex_shrink_0() + .items_center() + .gap_3() + .pl_5() + .pr_2() + .border_b_1() + .border_color(border) + .child(crumbs) + .child( + h_flex() + .ml_auto() + .gap_1() + .when(general, |actions| actions.child(restore)) + .child(close), + ) + .into_any_element() + } + + /// `AlertDialog` of `requestConfirmDialog` ("Restore default settings?", destructive). + fn restore_dialog(&self, cx: &mut Context) -> Option { + if !self.confirm_restore { + return None; + } + let theme = cx.theme(); + let (border, background, muted) = (theme.border, theme.background, theme.muted_foreground); + let backdrop = background.opacity(if theme.is_dark() { 0.64 } else { 0.6 }); + let labels = self.settings.changed_labels().join(", "); + let cancel = div() + .id("settings-restore-cancel") + .h(px(32.)) + .px(px(12.)) + .flex() + .items_center() + .rounded(px(8.)) + .border_1() + .border_color(border) + .text_size(px(14.)) + .line_height(px(20.)) + .font_weight(FontWeight::MEDIUM) + .cursor_pointer() + .hover(|style| style.bg(theme.foreground.opacity(0.06))) + .on_click(cx.listener(|this, _, _, cx| { + this.confirm_restore = false; + cx.notify(); + })) + .child("Cancel"); + let confirm = div() + .id("settings-restore-confirm") + .h(px(32.)) + .px(px(12.)) + .flex() + .items_center() + .rounded(px(8.)) + .bg(theme.danger) + .text_color(theme.danger_foreground) + .text_size(px(14.)) + .line_height(px(20.)) + .font_weight(FontWeight::MEDIUM) + .cursor_pointer() + .on_click(cx.listener(|this, _, _, cx| this.restore_defaults(cx))) + .child("Confirm"); + Some( + div() + .absolute() + .inset_0() + .child( + div() + .id("settings-restore-backdrop") + .absolute() + .inset_0() + .occlude() + .bg(backdrop) + .on_click(cx.listener(|this, _, _, cx| { + this.confirm_restore = false; + cx.notify(); + })), + ) + .child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child( + v_flex() + .occlude() + .w(px(448.)) + .max_w_full() + .gap_4() + .p_6() + .rounded(px(16.)) + .border_1() + .border_color(border) + .bg(background) + .shadow_lg() + .child( + v_flex() + .gap_2() + .child( + div() + .text_xl() + .font_weight(FontWeight::SEMIBOLD) + .child("Restore default settings?"), + ) + .child( + div() + .text_size(px(14.)) + .line_height(px(20.)) + .text_color(muted) + .child(format!("This will reset: {labels}.")), + ), + ) + .child(h_flex().justify_end().gap_2().child(cancel).child(confirm)), + ), + ) + .into_any_element(), + ) + } + + /// `SettingsSidebarNav`: `w-60 border-e bg-sidebar`, content inset `p-2`, rows `gap-2`. + fn nav(&self, window: &mut Window, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let (muted, sidebar_foreground, hover, border) = ( + theme.muted_foreground, + theme.sidebar_foreground, + theme.list_hover, + theme.sidebar_border, + ); + let (sidebar, accent, foreground, ring) = + (theme.sidebar, theme.accent, theme.foreground, theme.ring); + let query = self.query(cx); + let searching = !query.is_empty(); + let focused = self.search.focus_handle(cx).is_focused(window); + let kbd = div() + .h(px(20.)) + .min_w(px(20.)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.)) + .px_1() + .bg(foreground.opacity(0.07)) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(muted) + .child("/"); + let clear = div() + .id("settings-search-clear") + .size(px(20.)) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .rounded(px(4.)) + .text_color(muted) + .cursor_pointer() + .hover(move |style| style.bg(accent).text_color(foreground)) + .on_click(cx.listener(|this, _: &ClickEvent, window, cx| { + this.clear_search(window, cx); + this.focus_search(window, cx); + cx.notify(); + })) + .child(Icon::new(IconName::Close).size(px(12.))); + // Hover and focus paint the row; `h-8 rounded-md px-2 gap-2 text-sm font-medium`. + let search_row = h_flex() + .h_8() + .items_center() + .gap_2() + .px_2() + .rounded(px(6.)) + .border_1() + .border_color(transparent_black()) + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(muted) + .hover(move |style| style.bg(hover)) + .when(focused, |row| row.bg(hover).border_color(ring.opacity(0.7))) + .child( + div() + .flex_shrink_0() + .opacity(0.8) + .child(Icon::new(IconName::Search).size_4()), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_color(sidebar_foreground) + .child(Input::new(&self.search).appearance(false).small()), + ) + .child(if searching { + clear.into_any_element() + } else { + kbd.into_any_element() + }); + let body = if searching { + self.search_results(cx) + } else { + self.groups(cx) + }; + v_flex() + .w(px(NAV_WIDTH)) + .h_full() + .flex_shrink_0() + .border_r_1() + .border_color(border) + .bg(sidebar) + .text_color(sidebar_foreground) + .child( + div() + .id("settings-nav-scroll") + .flex_1() + .min_h_0() + .overflow_y_scroll() + .p_2() + .child(v_flex().gap_2().child(search_row).child(body)), + ) + .into_any_element() + } + + fn section_icon(section: Section, cx: &App) -> AnyElement { + let icon = match section { + Section::General => Icon::new(Icons::Settings2), + Section::Appearance => Icon::new(Icons::Palette), + Section::Chat => Icon::new(Icons::MessageSquareText), + Section::Diffs => Icon::new(Icons::FileDiff), + Section::Keybindings => Icon::new(Icons::Keyboard), + Section::SnapShot => Icon::empty().path(SNAP_SHOT), + Section::Providers => Icon::new(Icons::Bot), + Section::Threads => Icon::new(Icons::MessagesSquare), + Section::Browser => Icon::new(Icons::Globe), + Section::Devices => Icon::new(Icons::Smartphone), + Section::ScheduledTasks => Icon::new(Icons::CalendarClock), + Section::Projects => Icon::new(Icons::PanelsTopLeft), + Section::SourceControl => Icon::new(Icons::GitBranch), + Section::Worktrees => Icon::new(Icons::FolderTree), + Section::Connections => Icon::new(Icons::Server), + Section::Access => Icon::new(Icons::ShieldCheck), + Section::Secrets => Icon::new(Icons::KeyRound), + Section::Maintenance => Icon::new(Icons::Wrench), + Section::Labs => Icon::new(Icons::FlaskConical), + Section::About => Icon::new(Icons::Info), + Section::Archived => Icon::new(Icons::Archive), + }; + div() + .flex_shrink_0() + .text_color(cx.theme().muted_foreground.opacity(0.8)) + .child(icon.size_4()) + .into_any_element() + } + + /// `SidebarMenuButton`: `h-8 rounded-lg px-2.5 gap-2 text-sm`, muted until hovered or active. + fn groups(&self, cx: &mut Context) -> AnyElement { + let theme = cx.theme(); + let (muted, foreground, hover, selected) = ( + theme.muted_foreground, + theme.sidebar_foreground, + theme.list_hover, + theme.list_active, + ); + let mut groups = v_flex().gap_4(); + for (index, nav_group) in NAV_GROUPS.iter().enumerate() { + let mut rows = v_flex().gap_1(); + for section in nav_group.sections { + let target = *section; + let active = target == self.section; + let icon = Self::section_icon(target, cx); + rows = rows.child( + h_flex() + .id(("settings-section", index * 10 + target as usize)) + .h_8() + .items_center() + .gap_2() + .px(px(10.)) + .py(px(6.)) + .rounded(px(8.)) + .text_sm() + .font_weight(FontWeight::MEDIUM) + .cursor_pointer() + .text_color(if active { + foreground + } else { + muted.opacity(0.8) + }) + .when(active, |row| row.bg(selected)) + .when(!active, |row| { + row.hover(move |style| style.bg(hover).text_color(foreground)) + }) + .on_click( + cx.listener(move |this, _, _, cx| this.select_section(target, cx)), + ) + .child(icon) + .child(div().min_w_0().truncate().child(target.label())), + ); + } + groups = groups.child( + v_flex() + .gap_1() + .children(nav_group.heading.map(|heading| { + div() + .px_2() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(muted.opacity(0.8)) + .child(heading) + })) + .child(rows), + ); + } + groups.into_any_element() + } + + /// Results rows: `min-h-10 items-start`, title `text-sm font-medium`, section `text-2xs`. + fn search_results(&self, cx: &mut Context) -> AnyElement { + let results = self.results(cx); + if results.is_empty() { + return div() + .px_2() + .py_6() + .text_center() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child("No settings found") + .into_any_element(); + } + let theme = cx.theme(); + let (muted, foreground, selected) = ( + theme.muted_foreground, + theme.sidebar_foreground, + theme.list_active, + ); + v_flex() + .gap_1() + .children(results.into_iter().enumerate().map(|(index, item)| { + h_flex() + .id(("settings-result", index)) + .min_h(px(40.)) + .items_start() + .gap_2() + .px(px(10.)) + .py(px(6.)) + .rounded(px(8.)) + .cursor_pointer() + .when(index == self.active_result, |row| row.bg(selected)) + .on_mouse_move(cx.listener(move |this, _, _, cx| { + if this.active_result != index { + this.active_result = index; + cx.notify(); + } + })) + .on_click(cx.listener(move |this, _, window, cx| { + this.active_result = index; + this.activate_result(window, cx); + })) + .child(Self::section_icon(item.section, cx)) + .child( + v_flex() + .min_w_0() + .flex_1() + .child( + div() + .truncate() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(foreground) + .child(item.title), + ) + .child( + div() + .truncate() + .text_size(px(11.)) + .text_color(muted.opacity(0.75)) + .child(item.section.label()), + ), + ) + })) + .into_any_element() + } + + /// `SettingsPageContainer`: `max-w-4xl px-6 pt-6 pb-12 gap-8`. + fn body(&self, content_width: f32, window: &mut Window, cx: &mut Context) -> AnyElement { + let panel = if self.section == Section::General { + self.general(content_width >= ROW_WIDE + 48., window, cx) + } else { + Vec::new() + }; + div() + .w_full() + .flex() + .justify_center() + .child( + v_flex() + .w_full() + .max_w(px(CONTENT_MAX)) + .gap_8() + .px_6() + .pt_6() + .pb_12() + .children(panel), + ) + .into_any_element() + } + + fn reset( + &self, + id: &'static str, + changed: bool, + restore: fn(&mut DeviceSettings, &DeviceSettings), + window: &mut Window, + cx: &mut Context, + ) -> Option { + let view = cx.entity(); + changed.then(|| { + reset_button( + id, + move |_, _, cx| { + view.update(cx, |this, cx| { + this.update(|s| restore(s, &DeviceSettings::default()), cx) + }) + }, + window, + cx, + ) + }) + } + + fn flag( + &self, + id: &'static str, + checked: bool, + toggle: fn(&mut DeviceSettings), + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let view = cx.entity(); + switch( + id, + checked, + Rc::new(move |_, cx| view.update(cx, |this, cx| this.update(toggle, cx))), + window, + cx, + ) + } + + /// `Select` (select.tsx) over a `Popover`; Enter, Space and the arrows open it. + fn select(&self, pick: Pick, window: &mut Window, cx: &mut Context) -> AnyElement { + let view = cx.entity(); + let labels = pick.labels(); + let selected = pick.selected(&self.settings); + let open = matches!(self.menu, Some((open, _)) if open == pick); + let highlighted = match self.menu { + Some((open, index)) if open == pick => index, + _ => usize::MAX, + }; + let slot = Pick::ALL.iter().position(|p| *p == pick).unwrap_or(0); + let on_key: KeyHandler = { + let view = view.clone(); + Rc::new(move |event, _, cx| { + if matches!( + event.keystroke.key.as_str(), + "enter" | "space" | "down" | "up" + ) { + cx.stop_propagation(); + view.update(cx, |this, cx| this.open_menu(pick, cx)); + } + }) + }; + let trigger = SelectTrigger::new( + pick.id(), + labels[selected], + pick.width(), + self.triggers[slot].clone(), + window, + on_key, + ); + let on_pick: PickHandler = { + let view = view.clone(); + Rc::new(move |index, window, cx| { + view.update(cx, |this, cx| this.pick(pick, index, window, cx)) + }) + }; + let on_hover: HoverHandler = { + let view = view.clone(); + Rc::new(move |index, cx| { + view.update(cx, |this, cx| { + if this.menu.is_some_and(|(_, current)| current != index) { + this.menu = Some((pick, index)); + cx.notify(); + } + }) + }) + }; + Popover::new(("settings-select", slot)) + .anchor(Anchor::TopRight) + .offset(px(4.)) + .open(open) + .p_0() + .on_open_change(move |opened, window, cx| { + view.update(cx, |this, cx| { + if *opened { + this.open_menu(pick, cx); + } else if this.menu.is_some_and(|(current, _)| current == pick) { + this.close_menu(window, cx); + } + }) + }) + .trigger(trigger) + .content(move |_, _, cx| { + select_menu( + &Menu { + id: pick.id(), + labels: labels.clone(), + selected, + highlighted, + width: pick.width(), + on_pick: on_pick.clone(), + on_hover: on_hover.clone(), + }, + cx, + ) + }) + .into_any_element() + } + + /// `GeneralSettingsPanel` (SettingsPanels.tsx:781-1067): Notifications, Sidebar, + /// Confirmations, Display. + fn general(&self, wide: bool, window: &mut Window, cx: &mut Context) -> Vec { + let s = self.settings; + let d = DeviceSettings::default(); + let view = cx.entity(); + let grouping = { + let view = view.clone(); + switch( + "settings-project-grouping", + s.project_grouping != GroupingMode::Separate, + Rc::new(move |_, cx| view.update(cx, |this, cx| this.toggle_grouping(cx))), + window, + cx, + ) + }; + let rows = |items: Vec| { + items + .into_iter() + .map(IntoElement::into_any_element) + .collect() + }; + + let notifications = group( + rows(vec![ + row( + "Thread notifications", + "System alerts when a thread finishes, fails, or needs input or approval. Applies to this device while T3 Code is open.", + self.reset( + "settings-reset-thread-notifications", + s.notification_mode != d.notification_mode, + |s, d| s.notification_mode = d.notification_mode, + window, + cx, + ), + self.select(Pick::Notifications, window, cx), + wide, + cx, + ), + row( + "In-app notifications", + "Show a toast when another thread finishes, fails, or needs input or approval while this app has focus.", + self.reset( + "settings-reset-in-app-notifications", + s.in_app_notifications != d.in_app_notifications, + |s, d| s.in_app_notifications = d.in_app_notifications, + window, + cx, + ), + self.flag( + "settings-in-app-notifications", + s.in_app_notifications, + |s| s.in_app_notifications = !s.in_app_notifications, + window, + cx, + ), + wide, + cx, + ), + ]), + cx, + ); + let sidebar = group( + rows(vec![ + row( + "Project grouping", + "Combine matching repositories across environments.", + self.reset( + "settings-reset-project-grouping", + s.project_grouping != d.project_grouping, + |s, d| s.project_grouping = d.project_grouping, + window, + cx, + ), + grouping, + wide, + cx, + ), + row( + "Project order", + "Order of projects in the sidebar project picker and command palette.", + self.reset( + "settings-reset-project-order", + s.project_order != d.project_order, + |s, d| s.project_order = d.project_order, + window, + cx, + ), + self.select(Pick::ProjectOrder, window, cx), + wide, + cx, + ), + row( + "Working section", + "Fold working and monitoring threads into a Working section. They return to the top of the inbox when they need you. The boring sidebar in Labs has no Working section.", + self.reset( + "settings-reset-working-section", + s.working_shelf != d.working_shelf, + |s, d| s.working_shelf = d.working_shelf, + window, + cx, + ), + self.flag( + "settings-working-section", + s.working_shelf, + |s| s.working_shelf = !s.working_shelf, + window, + cx, + ), + wide, + cx, + ), + ]), + cx, + ); + let confirmations = group( + rows(vec![ + row( + "Unpin confirmation", + "Ask before unpinning a thread from the pinned section.", + self.reset( + "settings-reset-unpin-confirmation", + s.confirm_unpin != d.confirm_unpin, + |s, d| s.confirm_unpin = d.confirm_unpin, + window, + cx, + ), + self.flag( + "settings-unpin-confirmation", + s.confirm_unpin, + |s| s.confirm_unpin = !s.confirm_unpin, + window, + cx, + ), + wide, + cx, + ), + row( + "Archive confirmation", + "Require a second click on the inline archive action before a thread is archived.", + self.reset( + "settings-reset-archive-confirmation", + s.confirm_archive != d.confirm_archive, + |s, d| s.confirm_archive = d.confirm_archive, + window, + cx, + ), + self.flag( + "settings-archive-confirmation", + s.confirm_archive, + |s| s.confirm_archive = !s.confirm_archive, + window, + cx, + ), + wide, + cx, + ), + row( + "Delete confirmation", + "Ask before deleting a thread and its chat history.", + self.reset( + "settings-reset-delete-confirmation", + s.confirm_delete != d.confirm_delete, + |s, d| s.confirm_delete = d.confirm_delete, + window, + cx, + ), + self.flag( + "settings-delete-confirmation", + s.confirm_delete, + |s| s.confirm_delete = !s.confirm_delete, + window, + cx, + ), + wide, + cx, + ), + row( + "Quit shortcut", + "Hold mode also quits on two quick presses.", + self.reset( + "settings-reset-quit-shortcut", + s.confirm_quit != d.confirm_quit, + |s, d| s.confirm_quit = d.confirm_quit, + window, + cx, + ), + self.select(Pick::Quit, window, cx), + wide, + cx, + ), + ]), + cx, + ); + let display = group( + rows(vec![row( + "Time format", + "System default follows your browser or OS clock preference.", + self.reset( + "settings-reset-time-format", + s.time_format != d.time_format, + |s, d| s.time_format = d.time_format, + window, + cx, + ), + self.select(Pick::Time, window, cx), + wide, + cx, + )]), + cx, + ); + vec![ + section("Notifications", notifications).into_any_element(), + section("Sidebar", sidebar).into_any_element(), + section("Confirmations", confirmations).into_any_element(), + section("Display", display).into_any_element(), + ] + } +} diff --git a/apps/desktop-gpui/src/ui/settings/components.rs b/apps/desktop-gpui/src/ui/settings/components.rs new file mode 100644 index 000000000000..1b3f597e6aa4 --- /dev/null +++ b/apps/desktop-gpui/src/ui/settings/components.rs @@ -0,0 +1,405 @@ +//! Reusable pieces of the Settings route: `settingsLayout.tsx`, `SettingsGroup.tsx`, and the +//! `Switch`, `Select` and `Button` primitives they hold. + +use super::*; +use gpui::{BoxShadow, ClickEvent, ElementId, FocusHandle, KeyDownEvent, point}; +use std::rc::Rc; + +pub(super) type Toggle = Rc; +pub(super) type KeyHandler = Rc; +pub(super) type PickHandler = Rc; +pub(super) type HoverHandler = Rc; + +/// A tab stop kept across frames, keyed by the control's id. +pub(super) fn tab_stop(id: &ElementId, window: &mut Window, cx: &mut App) -> FocusHandle { + window + .use_keyed_state(id.clone(), cx, |_, cx| cx.focus_handle().tab_stop(true)) + .read(cx) + .clone() +} + +fn focus_visible(handle: &FocusHandle, window: &Window) -> bool { + handle.is_focused(window) && window.last_input_was_keyboard() +} + +/// `focus-visible:ring-2 ring-inset ring-ring ring-offset-1 ring-offset-background` +/// (switch.tsx, button.tsx): a 2px ring inside the control, over a 1px line in the background. +fn focus_ring(radius: f32, cx: &App) -> gpui::Div { + let theme = cx.theme(); + div() + .absolute() + .inset_0() + .rounded(px(radius)) + .border_1() + .border_color(theme.background) + .child( + div() + .absolute() + .inset_0() + .rounded(px((radius - 1.).max(0.))) + .border_2() + .border_color(theme.ring), + ) +} + +/// `SettingsSection` (settingsLayout.tsx): `space-y-2.5`, the title row `min-h-7 px-4`, the title +/// `text-base font-semibold`. +pub(super) fn section(title: &'static str, group: impl IntoElement) -> gpui::Div { + v_flex() + .gap(px(10.)) + .child( + h_flex().min_h(px(28.)).items_start().px_4().child( + h_flex() + .min_h(px(28.)) + .items_center() + .gap_2() + .text_base() + .font_weight(FontWeight::SEMIBOLD) + .child(title), + ), + ) + .child(group) +} + +/// `SettingsGroup` (SettingsGroup.tsx): `rounded-xl border border-border/60 bg-card/40 shadow-xs/5` +/// with `border-border/50` between rows. +pub(super) fn group(rows: Vec, cx: &App) -> gpui::Div { + let theme = cx.theme(); + let divider = theme.border.opacity(0.5); + let mut card = v_flex() + .relative() + .rounded(px(12.)) + .border_1() + .border_color(theme.border.opacity(0.6)) + .bg(theme.popover.opacity(0.4)) + .shadow(vec![BoxShadow { + color: gpui::black().opacity(0.05), + offset: point(px(0.), px(1.)), + blur_radius: px(2.), + spread_radius: px(0.), + inset: false, + }]); + for (index, row) in rows.into_iter().enumerate() { + if index > 0 { + card = card.child(div().h(px(1.)).w_full().bg(divider)); + } + card = card.child(row); + } + card +} + +/// `SettingsRow` (settingsLayout.tsx): `px-4 py-3`, the columns `minmax(0,1fr) minmax(10rem,auto)` +/// with `gap-8` from a 32rem container, stacked with `gap-3` below it. +pub(super) fn row( + title: &'static str, + description: impl IntoElement, + reset: Option, + control: AnyElement, + wide: bool, + cx: &App, +) -> gpui::Div { + let theme = cx.theme(); + let heading = h_flex() + .min_h(px(20.)) + .items_center() + .gap(px(6.)) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(theme.foreground) + .child(title), + ) + .child(div().size(px(20.)).flex_shrink_0().children(reset)); + let text = v_flex().min_w_0().flex_1().gap_1().child(heading).child( + div() + .max_w(px(672.)) + .text_xs() + .line_height(px(18.)) + .text_color(theme.muted_foreground) + .child(description), + ); + let control = h_flex() + .min_w_0() + .flex_shrink_0() + .items_center() + .gap_2() + .justify_end() + .child(control); + let inner = if wide { + h_flex() + .items_center() + .gap(px(32.)) + .child(text) + .child(div().min_w(px(160.)).child(control)) + } else { + v_flex().gap_3().child(text).child(control) + }; + div().px_4().py_3().child(inner) +} + +/// `SettingResetButton` (settingsLayout.tsx): `icon-micro` `ghost-muted`, `size-3` Undo2. +pub(super) fn reset_button( + id: &'static str, + on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let key = ElementId::from(id); + let handle = tab_stop(&key, window, cx); + let visible = focus_visible(&handle, window); + let theme = cx.theme(); + let (muted, hover, foreground) = (theme.muted_foreground, theme.accent, theme.foreground); + let ring = focus_ring(4., cx); + div() + .id(key) + .track_focus(&handle) + .relative() + .size(px(20.)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.)) + .text_color(muted) + .cursor_pointer() + .hover(move |style| style.bg(hover).text_color(foreground)) + .tooltip(tooltip("Reset to default")) + .on_click(on_click) + .child(Icon::new(Icons::Undo2).size(px(12.))) + .when(visible, |button| button.child(ring)) + .into_any_element() +} + +/// `Switch` (switch.tsx) at the desktop size: a 30x18 track with 2px padding and a 14px thumb +/// that travels 12px. Space and Enter toggle it. +pub(super) fn switch( + id: &'static str, + checked: bool, + on_toggle: Toggle, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let key = ElementId::from(id); + let handle = tab_stop(&key, window, cx); + let visible = focus_visible(&handle, window); + let theme = cx.theme(); + let track = if checked { theme.primary } else { theme.input }; + let thumb = if checked || !theme.is_dark() { + theme.background + } else { + theme.muted_foreground + }; + let ring = focus_ring(9., cx); + let click = on_toggle.clone(); + div() + .id(key) + .track_focus(&handle) + .relative() + .w(px(30.)) + .h(px(18.)) + .flex_shrink_0() + .rounded_full() + .p(px(2.)) + .bg(track) + .cursor_pointer() + .on_click(move |_, window, cx| click(window, cx)) + .on_key_down(move |event: &KeyDownEvent, window, cx| { + if matches!(event.keystroke.key.as_str(), "space" | "enter") { + cx.stop_propagation(); + on_toggle(window, cx); + } + }) + .child( + div() + .size(px(14.)) + .rounded_full() + .bg(thumb) + .ml(px(if checked { 12. } else { 0. })) + .shadow(vec![BoxShadow { + color: gpui::black().opacity(0.05), + offset: point(px(0.), px(1.)), + blur_radius: px(2.), + spread_radius: px(0.), + inset: false, + }]), + ) + .when(visible, |track| track.child(ring)) + .into_any_element() +} + +/// The `size="sm"` select trigger (select.tsx): `min-h-7 rounded-lg border`, `text-sm`, a +/// 14px chevron at 70% opacity. +#[derive(IntoElement)] +pub(super) struct SelectTrigger { + id: ElementId, + label: &'static str, + width: f32, + handle: FocusHandle, + focused: bool, + open: bool, + on_key: KeyHandler, +} + +impl SelectTrigger { + pub(super) fn new( + id: &'static str, + label: &'static str, + width: f32, + handle: FocusHandle, + window: &Window, + on_key: KeyHandler, + ) -> Self { + let id = ElementId::from(id); + let focused = handle.is_focused(window); + Self { + id, + label, + width, + handle, + focused, + open: false, + on_key, + } + } +} + +impl Selectable for SelectTrigger { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } + + fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + fn is_open(&self) -> bool { + self.open + } +} + +impl RenderOnce for SelectTrigger { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let background = if theme.is_dark() { + theme.input.opacity(0.32) + } else { + theme.background + }; + let (input, ring, foreground, muted) = ( + theme.input, + theme.ring, + theme.foreground, + theme.muted_foreground, + ); + let on_key = self.on_key; + let highlighted = self.focused || self.open; + h_flex() + .id(self.id) + .track_focus(&self.handle) + .w(px(self.width)) + .max_w_full() + .min_h(px(28.)) + .items_center() + .gap(px(6.)) + .pl(px(9.)) + .pr(px(9.)) + .rounded(px(8.)) + .border_1() + .border_color(if highlighted { ring } else { input }) + .bg(background) + .text_sm() + .text_color(foreground) + .cursor_pointer() + .shadow(if highlighted { + vec![BoxShadow { + color: ring.opacity(0.24), + offset: point(px(0.), px(0.)), + blur_radius: px(0.), + spread_radius: px(3.), + inset: false, + }] + } else { + vec![BoxShadow { + color: gpui::black().opacity(0.05), + offset: point(px(0.), px(1.)), + blur_radius: px(2.), + spread_radius: px(0.), + inset: false, + }] + }) + .on_key_down(move |event, window, cx| on_key(event, window, cx)) + .child(div().min_w_0().flex_1().truncate().child(self.label)) + .child( + div() + .mr(px(-4.)) + .flex_shrink_0() + .opacity(0.7) + .text_color(muted) + .child(Icon::new(Icons::ChevronDown).size(px(14.))), + ) + } +} + +/// The select popup (select.tsx): `rounded-lg`, `p-1`, items `min-h-7 rounded-sm px-2 py-1 +/// text-sm`, the chosen item at `foreground/8%` and the highlighted one on `accent`. +pub(super) struct Menu { + pub id: &'static str, + pub labels: Vec<&'static str>, + pub selected: usize, + pub highlighted: usize, + pub width: f32, + pub on_pick: PickHandler, + pub on_hover: HoverHandler, +} + +pub(super) fn select_menu(menu: &Menu, cx: &App) -> gpui::Div { + let Menu { + id, + labels, + selected, + highlighted, + width, + on_pick, + on_hover, + } = menu; + let (selected, highlighted, id) = (*selected, *highlighted, *id); + let theme = cx.theme(); + let (accent, accent_foreground, chosen) = ( + theme.accent, + theme.accent_foreground, + theme.foreground.opacity(0.08), + ); + v_flex() + .min_w(px(*width)) + .p_1() + .children(labels.iter().enumerate().map(|(index, label)| { + let pick = on_pick.clone(); + let hover = on_hover.clone(); + div() + .id((id, index)) + .min_h(px(28.)) + .flex() + .items_center() + .px_2() + .py_1() + .rounded(px(4.)) + .text_sm() + .cursor_pointer() + .when(index == selected, |item| item.bg(chosen)) + .when(index == highlighted, |item| { + item.bg(accent).text_color(accent_foreground) + }) + .on_hover(move |entered, _, cx| { + if *entered { + hover(index, cx); + } + }) + .on_click(move |_, window, cx| pick(index, window, cx)) + .child(*label) + })) +} From 4903118317be7090b2c019db2485253fda9201fe Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 9 Nov 2026 02:06:19 -0500 Subject: [PATCH 63/82] fix(desktop-gpui): settings modal colors follow the web tokens --- apps/desktop-gpui/src/ui/settings.rs | 184 +++++++++--------- .../src/ui/settings/components.rs | 144 +++++++++----- apps/desktop-gpui/src/ui/settings/tokens.rs | 101 ++++++++++ 3 files changed, 296 insertions(+), 133 deletions(-) create mode 100644 apps/desktop-gpui/src/ui/settings/tokens.rs diff --git a/apps/desktop-gpui/src/ui/settings.rs b/apps/desktop-gpui/src/ui/settings.rs index 03df508f54cf..2c3f85cb9778 100644 --- a/apps/desktop-gpui/src/ui/settings.rs +++ b/apps/desktop-gpui/src/ui/settings.rs @@ -2,6 +2,7 @@ //! navigation (`SettingsSidebarNav.tsx`) and the General tab (`GeneralSettingsPanel`). mod components; +mod tokens; use super::*; use crate::assets::SNAP_SHOT; @@ -10,14 +11,12 @@ use crate::settings::{ QuitMode, SearchItem, Section, SortOrder, TimeFormat, }; use components::{ - HoverHandler, KeyHandler, Menu, PickHandler, SelectTrigger, group, reset_button, row, section, - select_menu, switch, -}; -use gpui::{ - ClickEvent, FocusHandle, KeyBinding, KeyDownEvent, ScrollHandle, Subscription, - transparent_black, + HoverHandler, KeyHandler, Menu, PickHandler, SelectTrigger, close_button, ghost_button, group, + reset_button, row, section, select_menu, switch, }; +use gpui::{ClickEvent, FocusHandle, KeyBinding, KeyDownEvent, ScrollHandle, Subscription}; use std::rc::Rc; +use tokens::Tokens; const CONTEXT: &str = "SettingsPage"; /// `SettingsSidebarNav`'s `w-60`. @@ -331,7 +330,6 @@ impl Render for SettingsDialog { if !self.open { return gpui::Empty.into_any_element(); } - let theme = cx.theme(); let viewport = window.viewport_size(); let (vw, vh) = (f32::from(viewport.width), f32::from(viewport.height)); let pad = if vw >= 1024. { @@ -347,11 +345,9 @@ impl Render for SettingsDialog { } else { (vw, vh) }; - let backdrop = theme - .background - .opacity(if theme.is_dark() { 0.64 } else { 0.6 }); - let (border, background) = (theme.border, theme.background); - let nav = self.nav(window, cx); + let tokens = Tokens::of(cx); + let (backdrop, border, background) = (tokens.backdrop(), tokens.border, tokens.background); + let nav = self.nav(cx); let header = self.header(cx); let body = self.body(width - NAV_WIDTH, window, cx); let confirm = self.restore_dialog(cx); @@ -384,13 +380,19 @@ impl Render for SettingsDialog { .overflow_hidden() .occlude() .bg(background) - .text_color(cx.theme().foreground) + .text_color(tokens.foreground) .when(framed, |popup| { popup .rounded(px(16.)) .border_1() .border_color(border) - .shadow_2xl() + .shadow(vec![gpui::BoxShadow { + color: gpui::black().opacity(0.4), + offset: gpui::point(px(0.), px(25.)), + blur_radius: px(50.), + spread_radius: px(-12.), + inset: false, + }]) }) .child( h_flex().relative().flex_1().min_h_0().child(nav).child( @@ -414,13 +416,8 @@ impl Render for SettingsDialog { impl SettingsDialog { /// `SettingsContentLayout`'s header: `h-12 gap-3 border-b ps-5 pe-2`. fn header(&self, cx: &mut Context) -> AnyElement { - let theme = cx.theme(); - let (muted, border, foreground, hover) = ( - theme.muted_foreground, - theme.border, - theme.foreground, - theme.accent, - ); + let theme = Tokens::of(cx); + let (muted, border, foreground) = (theme.muted_foreground, theme.border, theme.foreground); let general = self.section == Section::General; let disabled = self.settings.changed_labels().is_empty(); let crumbs = h_flex() @@ -438,43 +435,28 @@ impl SettingsDialog { .text_color(foreground) .child(self.section.label()), ); - // `size="xs" variant="ghost"`: `h-6 gap-1 px-[7px] text-xs`, icon `mx-1 size-3.5`. - let restore = h_flex() - .id("settings-restore") + // `size="xs" variant="ghost"` (_chat.settings.tsx:33): `sm:h-6 gap-1 px-[7px] sm:text-xs`, + // `disabled:opacity-64 disabled:pointer-events-none`, icon `size-3.5` at `-mx-0.5`. + let restore = ghost_button("settings-restore", !disabled, cx) .h(px(24.)) - .items_center() .gap_1() .px(px(7.)) - .rounded(px(8.)) - .border_1() - .border_color(transparent_black()) .text_xs() - .text_color(foreground) - .when(disabled, |button| button.opacity(0.64)) .when(!disabled, |button| { - button - .cursor_pointer() - .hover(move |style| style.bg(hover)) - .on_click(cx.listener(|this, _, _, cx| { - this.confirm_restore = true; - cx.notify(); - })) + button.on_click(cx.listener(|this, _, _, cx| { + this.confirm_restore = true; + cx.notify(); + })) }) .child( div() - .mx_1() + .mx(px(-2.)) .text_color(muted) .child(Icon::new(Icons::RotateCcw).size(px(14.))), ) .child("Restore device defaults"); - let close = ghost_icon_button( - "settings-close", - Icon::new(IconName::Close).size(px(14.)), - "Close (Esc)".into(), - true, - cx, - ) - .on_click(cx.listener(|this, _, window, cx| this.close(window, cx))); + let close = close_button("settings-close", cx) + .on_click(cx.listener(|this, _, window, cx| this.close(window, cx))); h_flex() .h(px(48.)) .flex_shrink_0() @@ -500,9 +482,20 @@ impl SettingsDialog { if !self.confirm_restore { return None; } - let theme = cx.theme(); - let (border, background, muted) = (theme.border, theme.background, theme.muted_foreground); - let backdrop = background.opacity(if theme.is_dark() { 0.64 } else { 0.6 }); + let theme = Tokens::of(cx); + let (background, muted, backdrop) = + (theme.background, theme.muted_foreground, theme.backdrop()); + // `dialog-glass` border (index.css:425-437). + let border = if theme.dark { + gpui::white().opacity(0.08) + } else { + theme.foreground.opacity(0.1) + }; + let (outline_bg, outline_hover) = if theme.dark { + (theme.input.opacity(0.32), theme.input.opacity(0.64)) + } else { + (theme.popover, theme.accent.opacity(0.5)) + }; let labels = self.settings.changed_labels().join(", "); let cancel = div() .id("settings-restore-cancel") @@ -512,12 +505,14 @@ impl SettingsDialog { .items_center() .rounded(px(8.)) .border_1() - .border_color(border) + .border_color(theme.input) + .bg(outline_bg) + .text_color(theme.foreground) .text_size(px(14.)) .line_height(px(20.)) .font_weight(FontWeight::MEDIUM) .cursor_pointer() - .hover(|style| style.bg(theme.foreground.opacity(0.06))) + .hover(move |style| style.bg(outline_hover)) .on_click(cx.listener(|this, _, _, cx| { this.confirm_restore = false; cx.notify(); @@ -530,8 +525,10 @@ impl SettingsDialog { .flex() .items_center() .rounded(px(8.)) - .bg(theme.danger) - .text_color(theme.danger_foreground) + .border_1() + .border_color(theme.destructive) + .bg(theme.destructive) + .text_color(gpui::white()) .text_size(px(14.)) .line_height(px(20.)) .font_weight(FontWeight::MEDIUM) @@ -572,7 +569,17 @@ impl SettingsDialog { .border_1() .border_color(border) .bg(background) - .shadow_lg() + .shadow(vec![gpui::BoxShadow { + color: gpui::black().opacity(if theme.dark { + 0.9 + } else { + 0.65 + }), + offset: gpui::point(px(0.), px(24.)), + blur_radius: px(if theme.dark { 72. } else { 64. }), + spread_radius: px(if theme.dark { -20. } else { -24. }), + inset: false, + }]) .child( v_flex() .gap_2() @@ -598,19 +605,17 @@ impl SettingsDialog { } /// `SettingsSidebarNav`: `w-60 border-e bg-sidebar`, content inset `p-2`, rows `gap-2`. - fn nav(&self, window: &mut Window, cx: &mut Context) -> AnyElement { - let theme = cx.theme(); + fn nav(&self, cx: &mut Context) -> AnyElement { + let theme = Tokens::of(cx); let (muted, sidebar_foreground, hover, border) = ( theme.muted_foreground, - theme.sidebar_foreground, - theme.list_hover, - theme.sidebar_border, + theme.foreground, + theme.sidebar_row_hover, + theme.border, ); - let (sidebar, accent, foreground, ring) = - (theme.sidebar, theme.accent, theme.foreground, theme.ring); + let (sidebar, foreground) = (theme.sidebar, theme.foreground); let query = self.query(cx); let searching = !query.is_empty(); - let focused = self.search.focus_handle(cx).is_focused(window); let kbd = div() .h(px(20.)) .min_w(px(20.)) @@ -634,27 +639,24 @@ impl SettingsDialog { .rounded(px(4.)) .text_color(muted) .cursor_pointer() - .hover(move |style| style.bg(accent).text_color(foreground)) + .hover(move |style| style.bg(hover).text_color(foreground)) .on_click(cx.listener(|this, _: &ClickEvent, window, cx| { this.clear_search(window, cx); this.focus_search(window, cx); cx.notify(); })) .child(Icon::new(IconName::Close).size(px(12.))); - // Hover and focus paint the row; `h-8 rounded-md px-2 gap-2 text-sm font-medium`. + // `h-8 rounded-md px-2 gap-2 text-sm font-medium`; only hover paints the row. let search_row = h_flex() .h_8() .items_center() .gap_2() .px_2() .rounded(px(6.)) - .border_1() - .border_color(transparent_black()) .text_sm() .font_weight(FontWeight::MEDIUM) .text_color(muted) - .hover(move |style| style.bg(hover)) - .when(focused, |row| row.bg(hover).border_color(ring.opacity(0.7))) + .hover(move |style| style.bg(hover).text_color(foreground)) .child( div() .flex_shrink_0() @@ -698,7 +700,7 @@ impl SettingsDialog { .into_any_element() } - fn section_icon(section: Section, cx: &App) -> AnyElement { + fn section_icon(section: Section, active: bool, cx: &App) -> AnyElement { let icon = match section { Section::General => Icon::new(Icons::Settings2), Section::Appearance => Icon::new(Icons::Palette), @@ -724,19 +726,23 @@ impl SettingsDialog { }; div() .flex_shrink_0() - .text_color(cx.theme().muted_foreground.opacity(0.8)) + .text_color(if active { + Tokens::of(cx).foreground + } else { + Tokens::of(cx).sidebar_icon + }) .child(icon.size_4()) .into_any_element() } /// `SidebarMenuButton`: `h-8 rounded-lg px-2.5 gap-2 text-sm`, muted until hovered or active. fn groups(&self, cx: &mut Context) -> AnyElement { - let theme = cx.theme(); + let theme = Tokens::of(cx); let (muted, foreground, hover, selected) = ( theme.muted_foreground, - theme.sidebar_foreground, - theme.list_hover, - theme.list_active, + theme.foreground, + theme.sidebar_row_hover, + theme.sidebar_row_selected, ); let mut groups = v_flex().gap_4(); for (index, nav_group) in NAV_GROUPS.iter().enumerate() { @@ -744,7 +750,7 @@ impl SettingsDialog { for section in nav_group.sections { let target = *section; let active = target == self.section; - let icon = Self::section_icon(target, cx); + let icon = Self::section_icon(target, active, cx); rows = rows.child( h_flex() .id(("settings-section", index * 10 + target as usize)) @@ -799,15 +805,15 @@ impl SettingsDialog { .py_6() .text_center() .text_xs() - .text_color(cx.theme().muted_foreground) + .text_color(Tokens::of(cx).muted_foreground) .child("No settings found") .into_any_element(); } - let theme = cx.theme(); + let theme = Tokens::of(cx); let (muted, foreground, selected) = ( theme.muted_foreground, - theme.sidebar_foreground, - theme.list_active, + theme.foreground, + theme.sidebar_row_selected, ); v_flex() .gap_1() @@ -818,7 +824,7 @@ impl SettingsDialog { .items_start() .gap_2() .px(px(10.)) - .py(px(6.)) + .py(px(8.)) .rounded(px(8.)) .cursor_pointer() .when(index == self.active_result, |row| row.bg(selected)) @@ -832,7 +838,11 @@ impl SettingsDialog { this.active_result = index; this.activate_result(window, cx); })) - .child(Self::section_icon(item.section, cx)) + .child(Self::section_icon( + item.section, + index == self.active_result, + cx, + )) .child( v_flex() .min_w_0() @@ -923,7 +933,7 @@ impl SettingsDialog { } /// `Select` (select.tsx) over a `Popover`; Enter, Space and the arrows open it. - fn select(&self, pick: Pick, window: &mut Window, cx: &mut Context) -> AnyElement { + fn select(&self, pick: Pick, cx: &mut Context) -> AnyElement { let view = cx.entity(); let labels = pick.labels(); let selected = pick.selected(&self.settings); @@ -950,7 +960,6 @@ impl SettingsDialog { labels[selected], pick.width(), self.triggers[slot].clone(), - window, on_key, ); let on_pick: PickHandler = { @@ -975,6 +984,7 @@ impl SettingsDialog { .offset(px(4.)) .open(open) .p_0() + .appearance(false) .on_open_change(move |opened, window, cx| { view.update(cx, |this, cx| { if *opened { @@ -1037,7 +1047,7 @@ impl SettingsDialog { window, cx, ), - self.select(Pick::Notifications, window, cx), + self.select(Pick::Notifications, cx), wide, cx, ), @@ -1090,7 +1100,7 @@ impl SettingsDialog { window, cx, ), - self.select(Pick::ProjectOrder, window, cx), + self.select(Pick::ProjectOrder, cx), wide, cx, ), @@ -1189,7 +1199,7 @@ impl SettingsDialog { window, cx, ), - self.select(Pick::Quit, window, cx), + self.select(Pick::Quit, cx), wide, cx, ), @@ -1207,7 +1217,7 @@ impl SettingsDialog { window, cx, ), - self.select(Pick::Time, window, cx), + self.select(Pick::Time, cx), wide, cx, )]), diff --git a/apps/desktop-gpui/src/ui/settings/components.rs b/apps/desktop-gpui/src/ui/settings/components.rs index 1b3f597e6aa4..3bd84290a355 100644 --- a/apps/desktop-gpui/src/ui/settings/components.rs +++ b/apps/desktop-gpui/src/ui/settings/components.rs @@ -1,6 +1,7 @@ //! Reusable pieces of the Settings route: `settingsLayout.tsx`, `SettingsGroup.tsx`, and the //! `Switch`, `Select` and `Button` primitives they hold. +use super::tokens::Tokens; use super::*; use gpui::{BoxShadow, ClickEvent, ElementId, FocusHandle, KeyDownEvent, point}; use std::rc::Rc; @@ -25,7 +26,7 @@ fn focus_visible(handle: &FocusHandle, window: &Window) -> bool { /// `focus-visible:ring-2 ring-inset ring-ring ring-offset-1 ring-offset-background` /// (switch.tsx, button.tsx): a 2px ring inside the control, over a 1px line in the background. fn focus_ring(radius: f32, cx: &App) -> gpui::Div { - let theme = cx.theme(); + let theme = Tokens::of(cx); div() .absolute() .inset_0() @@ -64,14 +65,14 @@ pub(super) fn section(title: &'static str, group: impl IntoElement) -> gpui::Div /// `SettingsGroup` (SettingsGroup.tsx): `rounded-xl border border-border/60 bg-card/40 shadow-xs/5` /// with `border-border/50` between rows. pub(super) fn group(rows: Vec, cx: &App) -> gpui::Div { - let theme = cx.theme(); + let theme = Tokens::of(cx); let divider = theme.border.opacity(0.5); let mut card = v_flex() .relative() .rounded(px(12.)) .border_1() .border_color(theme.border.opacity(0.6)) - .bg(theme.popover.opacity(0.4)) + .bg(theme.card.opacity(0.4)) .shadow(vec![BoxShadow { color: gpui::black().opacity(0.05), offset: point(px(0.), px(1.)), @@ -98,7 +99,7 @@ pub(super) fn row( wide: bool, cx: &App, ) -> gpui::Div { - let theme = cx.theme(); + let theme = Tokens::of(cx); let heading = h_flex() .min_h(px(20.)) .items_center() @@ -148,7 +149,7 @@ pub(super) fn reset_button( let key = ElementId::from(id); let handle = tab_stop(&key, window, cx); let visible = focus_visible(&handle, window); - let theme = cx.theme(); + let theme = Tokens::of(cx); let (muted, hover, foreground) = (theme.muted_foreground, theme.accent, theme.foreground); let ring = focus_ring(4., cx); div() @@ -182,9 +183,9 @@ pub(super) fn switch( let key = ElementId::from(id); let handle = tab_stop(&key, window, cx); let visible = focus_visible(&handle, window); - let theme = cx.theme(); + let theme = Tokens::of(cx); let track = if checked { theme.primary } else { theme.input }; - let thumb = if checked || !theme.is_dark() { + let thumb = if checked || !theme.dark { theme.background } else { theme.muted_foreground @@ -235,7 +236,6 @@ pub(super) struct SelectTrigger { label: &'static str, width: f32, handle: FocusHandle, - focused: bool, open: bool, on_key: KeyHandler, } @@ -246,17 +246,14 @@ impl SelectTrigger { label: &'static str, width: f32, handle: FocusHandle, - window: &Window, on_key: KeyHandler, ) -> Self { let id = ElementId::from(id); - let focused = handle.is_focused(window); Self { id, label, width, handle, - focused, open: false, on_key, } @@ -283,21 +280,37 @@ impl Selectable for SelectTrigger { } impl RenderOnce for SelectTrigger { - fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { - let theme = cx.theme(); - let background = if theme.is_dark() { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = Tokens::of(cx); + // `dark:bg-input/32`; light is `bg-background`. + let background = if theme.dark { theme.input.opacity(0.32) } else { theme.background }; - let (input, ring, foreground, muted) = ( - theme.input, - theme.ring, - theme.foreground, - theme.muted_foreground, - ); let on_key = self.on_key; - let highlighted = self.focused || self.open; + // `focus-visible:border-ring focus-visible:ring-[3px] ring-ring/24 ring-inset`, and + // `shadow-none` while focused or pressed. + let focus_visible = !self.open && focus_visible(&self.handle, window); + let shadow = if focus_visible { + vec![BoxShadow { + color: theme.ring.opacity(0.24), + offset: point(px(0.), px(0.)), + blur_radius: px(0.), + spread_radius: px(3.), + inset: true, + }] + } else if self.open { + Vec::new() + } else { + vec![BoxShadow { + color: gpui::black().opacity(0.05), + offset: point(px(0.), px(1.)), + blur_radius: px(2.), + spread_radius: px(0.), + inset: false, + }] + }; h_flex() .id(self.id) .track_focus(&self.handle) @@ -306,32 +319,19 @@ impl RenderOnce for SelectTrigger { .min_h(px(28.)) .items_center() .gap(px(6.)) - .pl(px(9.)) - .pr(px(9.)) + .px(px(9.)) .rounded(px(8.)) .border_1() - .border_color(if highlighted { ring } else { input }) + .border_color(if focus_visible { + theme.ring + } else { + theme.input + }) .bg(background) .text_sm() - .text_color(foreground) + .text_color(theme.foreground) .cursor_pointer() - .shadow(if highlighted { - vec![BoxShadow { - color: ring.opacity(0.24), - offset: point(px(0.), px(0.)), - blur_radius: px(0.), - spread_radius: px(3.), - inset: false, - }] - } else { - vec![BoxShadow { - color: gpui::black().opacity(0.05), - offset: point(px(0.), px(1.)), - blur_radius: px(2.), - spread_radius: px(0.), - inset: false, - }] - }) + .shadow(shadow) .on_key_down(move |event, window, cx| on_key(event, window, cx)) .child(div().min_w_0().flex_1().truncate().child(self.label)) .child( @@ -339,14 +339,16 @@ impl RenderOnce for SelectTrigger { .mr(px(-4.)) .flex_shrink_0() .opacity(0.7) - .text_color(muted) + .text_color(theme.muted_foreground) .child(Icon::new(Icons::ChevronDown).size(px(14.))), ) } } -/// The select popup (select.tsx): `rounded-lg`, `p-1`, items `min-h-7 rounded-sm px-2 py-1 -/// text-sm`, the chosen item at `foreground/8%` and the highlighted one on `accent`. +/// The select popup (select.tsx:94-118, 144-167): `dropdown-glass` (index.css:454-465) on a +/// `rounded-lg` shell with `p-1`, items `min-h-7 rounded-sm px-2 py-1 text-sm`, the chosen item at +/// `foreground/[0.08]` and the highlighted one on `accent`. Painted opaque, as the web does where +/// backdrop blur is unsupported. pub(super) struct Menu { pub id: &'static str, pub labels: Vec<&'static str>, @@ -368,15 +370,31 @@ pub(super) fn select_menu(menu: &Menu, cx: &App) -> gpui::Div { on_hover, } = menu; let (selected, highlighted, id) = (*selected, *highlighted, *id); - let theme = cx.theme(); + let theme = Tokens::of(cx); let (accent, accent_foreground, chosen) = ( theme.accent, theme.accent_foreground, theme.foreground.opacity(0.08), ); + let shadow = if theme.dark { + (18., 44., 0.8) + } else { + (16., 40., 0.55) + }; v_flex() .min_w(px(*width)) .p_1() + .rounded(px(8.)) + .border_1() + .border_color(theme.foreground.opacity(0.1)) + .bg(theme.popover) + .shadow(vec![BoxShadow { + color: gpui::black().opacity(shadow.2), + offset: point(px(0.), px(shadow.0)), + blur_radius: px(shadow.1), + spread_radius: px(-18.), + inset: false, + }]) .children(labels.iter().enumerate().map(|(index, label)| { let pick = on_pick.clone(); let hover = on_hover.clone(); @@ -403,3 +421,37 @@ pub(super) fn select_menu(menu: &Menu, cx: &App) -> gpui::Div { .child(*label) })) } + +/// `Button variant="ghost"` (button.tsx:59-60): `rounded-[--control-radius]` (8px), a transparent +/// border, `text-foreground`, `bg-accent` on hover, and the icon in `--contrast-muted-foreground`. +pub(super) fn ghost_button(id: &'static str, enabled: bool, cx: &App) -> gpui::Stateful { + let theme = Tokens::of(cx); + let accent = theme.accent; + div() + .id(id) + .flex() + .items_center() + .justify_center() + .rounded(px(8.)) + .border_1() + .border_color(gpui::transparent_black()) + .text_color(theme.foreground) + .when(enabled, |button| { + button.cursor_pointer().hover(move |style| style.bg(accent)) + }) + .when(!enabled, |button| button.opacity(0.64)) +} + +/// `AppRouteModalClose`: `Button size="icon-sm" variant="ghost"` with a 14px X (`sm:size-7`). +pub(super) fn close_button(id: &'static str, cx: &App) -> gpui::Stateful { + let muted = Tokens::of(cx).muted_foreground; + ghost_button(id, true, cx) + .size(px(28.)) + .flex_shrink_0() + .tooltip(tooltip("Close (Esc)")) + .child( + div() + .text_color(muted) + .child(Icon::new(IconName::Close).size(px(14.))), + ) +} diff --git a/apps/desktop-gpui/src/ui/settings/tokens.rs b/apps/desktop-gpui/src/ui/settings/tokens.rs new file mode 100644 index 000000000000..ffa430b077de --- /dev/null +++ b/apps/desktop-gpui/src/ui/settings/tokens.rs @@ -0,0 +1,101 @@ +//! The web's CSS color tokens for the Settings route, resolved for the default theme. +//! +//! The route sits outside `[data-app-sidebar]`, so the `:root` blocks of `index.css` apply +//! (light 1113-1183, dark 1189-1228), not the sidebar overrides at 1231-1275. gpui-component's +//! theme fields differ from these, so the route reads this table instead. + +use gpui::{App, Hsla, rgb, white}; +use gpui_component::ActiveTheme as _; + +#[derive(Clone, Copy)] +pub(super) struct Tokens { + pub dark: bool, + pub background: Hsla, + pub foreground: Hsla, + pub card: Hsla, + pub popover: Hsla, + pub primary: Hsla, + pub muted_foreground: Hsla, + pub accent: Hsla, + pub accent_foreground: Hsla, + pub border: Hsla, + pub input: Hsla, + pub ring: Hsla, + pub destructive: Hsla, + pub sidebar: Hsla, + pub sidebar_row_hover: Hsla, + pub sidebar_row_selected: Hsla, + /// `--sidebar-icon-color`: `sidebar-muted-foreground` mixed 85% into `--sidebar` (index.css:106). + pub sidebar_icon: Hsla, +} + +fn mix(a: u32, b: u32, share_of_a: f32) -> Hsla { + let channel = |shift: u32| { + let (a, b) = (((a >> shift) & 0xff) as f32, ((b >> shift) & 0xff) as f32); + (a * share_of_a + b * (1. - share_of_a)).round() as u32 + }; + rgb((channel(16) << 16) | (channel(8) << 8) | channel(0)).into() +} + +impl Tokens { + pub(super) fn of(cx: &App) -> Self { + if cx.theme().is_dark() { + Self::dark() + } else { + Self::light() + } + } + + /// `:root` dark variant, index.css:1189-1228. + fn dark() -> Self { + // neutral-950 mixed 97% with white (`--card`, `--popover`). + let card = mix(0x0a0a0a, 0xffffff, 0.97); + Self { + dark: true, + background: rgb(0x000000).into(), + foreground: rgb(0xf5f5f5).into(), + card, + popover: card, + primary: rgb(0x346bf1).into(), + muted_foreground: rgb(0xa3a3a3).into(), + accent: white().opacity(0.04), + accent_foreground: rgb(0xf5f5f5).into(), + border: white().opacity(0.08), + input: white().opacity(0.11), + ring: rgb(0x346bf1).into(), + destructive: mix(0xef4444, 0xffffff, 0.9), + sidebar: card, + sidebar_row_hover: white().opacity(0.04), + sidebar_row_selected: white().opacity(0.03), + sidebar_icon: mix(0xa3a3a3, 0x111111, 0.85), + } + } + + /// `:root` light, index.css:1113-1183. + fn light() -> Self { + Self { + dark: false, + background: rgb(0xfcfcfc).into(), + foreground: rgb(0x18181b).into(), + card: rgb(0xffffff).into(), + popover: rgb(0xffffff).into(), + primary: rgb(0x1b4ed8).into(), + muted_foreground: rgb(0x71717a).into(), + accent: rgb(0xf4f4f5).into(), + accent_foreground: rgb(0x18181b).into(), + border: rgb(0xe4e4e7).into(), + input: rgb(0xd4d4d8).into(), + ring: rgb(0x1b4ed8).into(), + destructive: rgb(0xef4444).into(), + sidebar: rgb(0xfafafa).into(), + sidebar_row_hover: rgb(0xfcfcfc).into(), + sidebar_row_selected: rgb(0xffffff).into(), + sidebar_icon: mix(0x71717a, 0xfafafa, 0.85), + } + } + + /// `dialog-backdrop` (index.css:444-452): `--background` at 60%, 64% in dark. + pub(super) fn backdrop(&self) -> Hsla { + self.background.opacity(if self.dark { 0.64 } else { 0.6 }) + } +} From 477d879b2e1994806342f13fcc6c14cba79f86d0 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 9 Nov 2026 02:17:54 -0500 Subject: [PATCH 64/82] feat(desktop-gpui): chat, diffs and labs settings tabs; chat lane follows the web canvas layout --- apps/desktop-gpui/src/details.rs | 27 + apps/desktop-gpui/src/main.rs | 2 + apps/desktop-gpui/src/projects.rs | 6 +- apps/desktop-gpui/src/providers.rs | 16 + apps/desktop-gpui/src/settings.rs | 231 +++- apps/desktop-gpui/src/settings_diffs.rs | 471 +++++++ apps/desktop-gpui/src/settings_labs.rs | 1102 +++++++++++++++++ apps/desktop-gpui/src/ui.rs | 47 +- apps/desktop-gpui/src/ui/settings.rs | 275 +++- .../src/ui/settings/components.rs | 199 ++- .../src/ui/settings/diffs_panel.rs | 431 +++++++ .../src/ui/settings/labs_panel.rs | 673 ++++++++++ .../src/ui/settings/scope_pickers.rs | 326 +++++ 13 files changed, 3762 insertions(+), 44 deletions(-) create mode 100644 apps/desktop-gpui/src/settings_diffs.rs create mode 100644 apps/desktop-gpui/src/settings_labs.rs create mode 100644 apps/desktop-gpui/src/ui/settings/diffs_panel.rs create mode 100644 apps/desktop-gpui/src/ui/settings/labs_panel.rs create mode 100644 apps/desktop-gpui/src/ui/settings/scope_pickers.rs diff --git a/apps/desktop-gpui/src/details.rs b/apps/desktop-gpui/src/details.rs index 223cb1ddbea6..bd817bb37c1c 100644 --- a/apps/desktop-gpui/src/details.rs +++ b/apps/desktop-gpui/src/details.rs @@ -37,6 +37,24 @@ pub fn card_height(container_width: f32, container_height: f32) -> Option { (height >= MIN_CARD_HEIGHT).then_some(height) } +/// `MAX_CHAT_WIDTH`: the widest the chat lane grows (`canvasStore.ts`). +const MAX_CHAT_WIDTH: f32 = 768.; + +/// The chat lane's left and right insets in the main area (`resolveChatCanvasLayout`). The lane +/// stays centered in the full width, and only slides left, as little as it must, to keep +/// `CARD_CLEARANCE` between it and the floating card. +pub fn lane_insets(container_width: f32, card_shown: bool) -> (f32, f32) { + if !card_shown { + return (0., 0.); + } + let centered = (container_width - LANE_PADDING * 2.).clamp(0., MAX_CHAT_WIDTH); + let lane_right = container_width - CARD_WIDTH - CARD_GAP - CARD_CLEARANCE; + let width = centered.min(lane_right - LANE_PADDING).max(0.); + let center_left = (container_width - width) / 2.; + let left = center_left.min(lane_right - width).max(LANE_PADDING); + (left, (container_width - left - width).max(0.)) +} + /// How much of the card shows: rows drop out as the available height shrinks. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Density { @@ -413,6 +431,15 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn lane_stays_centered_until_the_card_forces_it_left() { + assert_eq!(lane_insets(1200., false), (0., 0.)); + assert_eq!(lane_insets(1600., true), (416., 416.)); + assert_eq!(lane_insets(1416., true), (324., 324.)); + assert_eq!(lane_insets(1208., true), (116., 324.)); + assert_eq!(lane_insets(1012., true), (48., 324.)); + } + fn inputs() -> Inputs { Inputs { has_cwd: true, diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 96e0846fa271..3aaba5ab4016 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -22,6 +22,8 @@ mod rpc; mod send; mod server; mod settings; +mod settings_diffs; +mod settings_labs; mod shell; mod slow_requests; mod status; diff --git a/apps/desktop-gpui/src/projects.rs b/apps/desktop-gpui/src/projects.rs index 34fb9877e6f0..93d80a615d93 100644 --- a/apps/desktop-gpui/src/projects.rs +++ b/apps/desktop-gpui/src/projects.rs @@ -10,7 +10,7 @@ use std::borrow::Cow; use std::collections::HashMap; /// `[name, light, dark]` as sRGB hex: Tailwind's 600 and 400 shades, in `PROJECT_ICON_COLORS` order. -const COLORS: [(&str, u32, u32); 18] = [ +pub(crate) const COLORS: [(&str, u32, u32); 18] = [ ("gray", 0x4b5563, 0x9ca3af), ("red", 0xdc2626, 0xf87171), ("orange", 0xea580c, 0xfb923c), @@ -133,7 +133,7 @@ fn words(text: &str) -> Vec<&str> { .collect() } -fn initials(name: &str) -> String { +pub(crate) fn initials(name: &str) -> String { let words = words(name); let Some(first_word) = words.first() else { return "PR".to_owned(); @@ -159,7 +159,7 @@ fn initials(name: &str) -> String { .collect() } -fn color_index(name: &str) -> usize { +pub(crate) fn color_index(name: &str) -> usize { let lowered = name.trim().to_lowercase(); let seed = if lowered.is_empty() { "project" diff --git a/apps/desktop-gpui/src/providers.rs b/apps/desktop-gpui/src/providers.rs index 7388a43a28f8..f016c53069e0 100644 --- a/apps/desktop-gpui/src/providers.rs +++ b/apps/desktop-gpui/src/providers.rs @@ -308,6 +308,13 @@ struct Settings { usage_price_overrides: Option>, #[serde(default, deserialize_with = "lenient_option")] usage_model_aliases: Option>, + /// The Labs tab's environment-wide flags (contracts settings.ts:1329, 1355, 1360). + #[serde(default)] + simlock_device_hosts_enabled: bool, + #[serde(default)] + guest_sharing_enabled: bool, + #[serde(default)] + linked_environments_enabled: bool, } #[derive(Deserialize)] @@ -512,6 +519,15 @@ impl Catalog { &self.available_editors } + /// The attached environment's Labs flags. + pub fn labs_flags(&self) -> crate::settings_labs::ServerFlags { + crate::settings_labs::ServerFlags { + guest_sharing: self.settings.guest_sharing_enabled, + linked_environments: self.settings.linked_environments_enabled, + simlock_hosts: self.settings.simlock_device_hosts_enabled, + } + } + /// The workspace and permission defaults of a fresh draft in `project`: its override, then the server settings. pub fn defaults(&self, project: Option<&str>) -> Defaults { let overrides = project.and_then(|id| self.settings.project_settings_overrides.get(id)); diff --git a/apps/desktop-gpui/src/settings.rs b/apps/desktop-gpui/src/settings.rs index d53cffd52cd6..b9a0b68a0aaf 100644 --- a/apps/desktop-gpui/src/settings.rs +++ b/apps/desktop-gpui/src/settings.rs @@ -106,7 +106,72 @@ choice! { } } -/// The General tab's slice of the client settings, with `DEFAULT_CLIENT_SETTINGS` defaults. +/// `SendShortcut`; written by hand because its labels depend on the platform modifier. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SendShortcut { + Enter, + ModEnterMultiline, + ModEnter, +} + +choice! { + /// `FollowUpBehavior`. + FollowUp { + Queue => ("queue", "Queue"), + Steer => ("steer", "Steer"), + } +} + +/// The platform's primary modifier as the Chat tab prints it. +pub fn mod_label(mac: bool) -> &'static str { + if mac { "\u{2318}" } else { "Ctrl" } +} + +impl SendShortcut { + pub const ALL: &'static [Self] = &[Self::Enter, Self::ModEnterMultiline, Self::ModEnter]; + + pub fn wire(self) -> &'static str { + match self { + Self::Enter => "enter", + Self::ModEnterMultiline => "mod-enter-multiline", + Self::ModEnter => "mod-enter", + } + } + + pub fn from_wire(value: &str) -> Option { + Self::ALL.iter().copied().find(|mode| mode.wire() == value) + } + + pub fn menu_label(self, mac: bool) -> &'static str { + match (self, mac) { + (Self::Enter, _) => "Enter", + (Self::ModEnterMultiline, true) => "\u{2318} + Enter for multiline prompts", + (Self::ModEnterMultiline, false) => "Ctrl + Enter for multiline prompts", + (Self::ModEnter, true) => "\u{2318} + Enter always", + (Self::ModEnter, false) => "Ctrl + Enter always", + } + } +} + +/// The Follow-up behavior row's description (`SettingsPanels.tsx` Follow-ups section). +pub fn follow_up_description(send: SendShortcut, mac: bool) -> String { + let m = mod_label(mac); + let opposite = if send == SendShortcut::ModEnterMultiline { + format!( + "Press {m} + Enter for single-line prompts or {m} + Shift + Enter for multiline prompts to do the opposite for one message." + ) + } else { + let shift = if send == SendShortcut::ModEnter { + " + Shift" + } else { + "" + }; + format!("Press {m}{shift} + Enter to do the opposite for one message.") + }; + format!("Queue follow-ups while the agent runs or steer the current run. {opposite}") +} + +/// The General and Chat tabs' slice of the client settings, with `DEFAULT_CLIENT_SETTINGS` defaults. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct DeviceSettings { pub notification_mode: NotificationMode, @@ -119,6 +184,10 @@ pub struct DeviceSettings { pub confirm_delete: bool, pub confirm_quit: QuitMode, pub time_format: TimeFormat, + pub composer_rich_text: bool, + pub send_shortcut: SendShortcut, + pub follow_up: FollowUp, + pub skills_in_slash_menu: bool, } impl Default for DeviceSettings { @@ -134,6 +203,10 @@ impl Default for DeviceSettings { confirm_delete: true, confirm_quit: QuitMode::Hold, time_format: TimeFormat::Locale, + composer_rich_text: true, + send_shortcut: SendShortcut::Enter, + follow_up: FollowUp::Steer, + skills_in_slash_menu: true, } } } @@ -183,10 +256,18 @@ impl DeviceSettings { time_format: choice("timestampFormat") .and_then(TimeFormat::from_wire) .unwrap_or(defaults.time_format), + composer_rich_text: flag("composerRichTextEnabled", defaults.composer_rich_text), + send_shortcut: choice("sendShortcut") + .and_then(SendShortcut::from_wire) + .unwrap_or(defaults.send_shortcut), + follow_up: choice("followUpBehavior") + .and_then(FollowUp::from_wire) + .unwrap_or(defaults.follow_up), + skills_in_slash_menu: flag("showSkillsInSlashMenu", defaults.skills_in_slash_menu), }) } - pub fn entries(self) -> [(&'static str, Value); 10] { + pub fn entries(self) -> [(&'static str, Value); 14] { [ ("notificationMode", json!(self.notification_mode.wire())), ( @@ -204,6 +285,10 @@ impl DeviceSettings { ("confirmThreadDelete", json!(self.confirm_delete)), ("confirmQuit", json!(self.confirm_quit.wire())), ("timestampFormat", json!(self.time_format.wire())), + ("composerRichTextEnabled", json!(self.composer_rich_text)), + ("sendShortcut", json!(self.send_shortcut.wire())), + ("followUpBehavior", json!(self.follow_up.wire())), + ("showSkillsInSlashMenu", json!(self.skills_in_slash_menu)), ] } @@ -263,6 +348,16 @@ impl DeviceSettings { ), (self.project_order != d.project_order, "Project order"), (self.working_shelf != d.working_shelf, "Working section"), + ( + self.skills_in_slash_menu != d.skills_in_slash_menu, + "Show skills in slash menu", + ), + ( + self.composer_rich_text != d.composer_rich_text, + "Rich text composer", + ), + (self.send_shortcut != d.send_shortcut, "Send shortcut"), + (self.follow_up != d.follow_up, "Follow-up behavior"), (self.confirm_unpin != d.confirm_unpin, "Unpin confirmation"), ( self.confirm_archive != d.confirm_archive, @@ -423,8 +518,38 @@ const fn item( } } -/// The General tab's `searchableSetting` entries, in the web's order. The other tabs have no -/// panel here yet, so their settings are not searchable. +const fn chat_item(id: &'static str, title: &'static str, terms: &'static str) -> SearchItem { + SearchItem { + section: Section::Chat, + ..item(id, title, terms, false) + } +} + +/// The Chat tab's `searchableSetting` entries (`settingsSearch.ts:580-602`). +pub const CHAT_ITEMS: &[SearchItem] = &[ + chat_item( + "skills-in-slash-menu", + "Show skills in slash menu", + "command menu dollar $ slash /", + ), + chat_item( + "composer-rich-text", + "Rich text composer", + "composer rich text tiptap bold italic markdown styled wysiwyg", + ), + chat_item( + "send-shortcut", + "Send shortcut", + "enter return command ctrl multiline prompt new line composer", + ), + chat_item( + "follow-up-behavior", + "Follow-up behavior", + "queue steer running turn send default behavior composer", + ), +]; + +/// The General tab's `searchableSetting` entries, in the web's order. pub const GENERAL_ITEMS: &[SearchItem] = &[ item( "project-grouping", @@ -527,16 +652,34 @@ fn is_combining(ch: char) -> bool { matches!(ch, '\u{0300}'..='\u{036f}') } +/// Every searchable setting of the tabs that have a panel, in the web's order +/// (`settingsSearch.ts`): General, Labs, Diffs, then Chat. +pub fn searchable() -> &'static [SearchItem] { + static ITEMS: std::sync::OnceLock> = std::sync::OnceLock::new(); + ITEMS.get_or_init(|| { + GENERAL_ITEMS + .iter() + .chain(crate::settings_labs::LABS_ITEMS) + .chain(crate::settings_diffs::DIFFS_ITEMS) + .chain(CHAT_ITEMS) + .copied() + .collect() + }) +} + /// `searchSettings`: every token must appear in the title, the section label or the terms; /// ranked title-exact, title-prefix, title-substring, all-tokens-in-title, phrase anywhere. -pub fn search<'a>(query: &str, items: &'a [SearchItem]) -> Vec<&'a SearchItem> { +pub fn search<'a>( + query: &str, + items: impl IntoIterator, +) -> Vec<&'a SearchItem> { let query = normalize(query); if query.is_empty() { return Vec::new(); } let tokens: Vec<&str> = query.split(' ').collect(); let mut hits: Vec<(u8, usize, &SearchItem)> = items - .iter() + .into_iter() .enumerate() .filter_map(|(index, item)| { let title = normalize(item.title); @@ -602,6 +745,10 @@ mod tests { confirm_delete: false, confirm_quit: QuitMode::DoubleClick, time_format: TimeFormat::Hour24, + composer_rich_text: false, + send_shortcut: SendShortcut::ModEnter, + follow_up: FollowUp::Queue, + skills_in_slash_menu: false, }; assert_eq!(DeviceSettings::parse(&changed.to_json(None)), Some(changed)); } @@ -654,6 +801,78 @@ mod tests { assert!(settings.changed_labels().is_empty()); } + #[test] + fn chat_labels_sit_between_working_section_and_unpin() { + let settings = DeviceSettings { + working_shelf: false, + confirm_unpin: true, + follow_up: FollowUp::Queue, + send_shortcut: SendShortcut::ModEnter, + composer_rich_text: false, + skills_in_slash_menu: false, + ..DeviceSettings::default() + }; + assert_eq!( + settings.changed_labels(), + [ + "Working section", + "Show skills in slash menu", + "Rich text composer", + "Send shortcut", + "Follow-up behavior", + "Unpin confirmation" + ] + ); + } + + #[test] + fn chat_keys_parse_and_fall_back() { + let parsed = DeviceSettings::parse( + r#"{"sendShortcut":"mod-enter-multiline","followUpBehavior":"nope","composerRichTextEnabled":false}"#, + ) + .unwrap(); + assert_eq!(parsed.send_shortcut, SendShortcut::ModEnterMultiline); + assert_eq!(parsed.follow_up, FollowUp::Steer); + assert!(!parsed.composer_rich_text); + assert!(parsed.skills_in_slash_menu); + } + + #[test] + fn follow_up_description_names_the_opposite_shortcut() { + let base = "Queue follow-ups while the agent runs or steer the current run. "; + assert_eq!( + follow_up_description(SendShortcut::Enter, false), + format!("{base}Press Ctrl + Enter to do the opposite for one message.") + ); + assert_eq!( + follow_up_description(SendShortcut::ModEnter, true), + format!("{base}Press \u{2318} + Shift + Enter to do the opposite for one message.") + ); + assert_eq!( + follow_up_description(SendShortcut::ModEnterMultiline, false), + format!( + "{base}Press Ctrl + Enter for single-line prompts or Ctrl + Shift + Enter for multiline prompts to do the opposite for one message." + ) + ); + assert_eq!( + SendShortcut::ModEnterMultiline.menu_label(true), + "\u{2318} + Enter for multiline prompts" + ); + } + + #[test] + fn chat_items_are_searchable_alongside_general() { + let ids: Vec<&str> = search("composer", GENERAL_ITEMS.iter().chain(CHAT_ITEMS)) + .iter() + .map(|i| i.id) + .collect(); + assert_eq!( + ids, + ["composer-rich-text", "send-shortcut", "follow-up-behavior"] + ); + assert_eq!(search("$", CHAT_ITEMS)[0].section, Section::Chat); + } + #[test] fn grouping_toggle_remembers_the_last_enabled_mode() { use GroupingMode::*; diff --git a/apps/desktop-gpui/src/settings_diffs.rs b/apps/desktop-gpui/src/settings_diffs.rs new file mode 100644 index 000000000000..922db25572bd --- /dev/null +++ b/apps/desktop-gpui/src/settings_diffs.rs @@ -0,0 +1,471 @@ +//! The "Diffs & pull requests" tab's slice of the device settings (`ClientSettingsSchema`, +//! contracts/src/settings.ts:353). Every key here is a client setting, not a server setting, so +//! it lives in `client-settings.json` beside the General tab's keys, which this store keeps. + +use crate::settings::{SearchItem, Section}; +use serde_json::{Map, Value, json}; +use std::path::PathBuf; + +/// `DiffLayout` (settings.ts:59); labels from `DIFF_LAYOUT_LABELS` (SettingsPanels.tsx:136). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DiffLayout { + Stacked, + Split, +} + +impl DiffLayout { + pub const ALL: &'static [Self] = &[Self::Stacked, Self::Split]; + + pub fn wire(self) -> &'static str { + match self { + Self::Stacked => "stacked", + Self::Split => "split", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Stacked => "Stacked", + Self::Split => "Split", + } + } + + pub fn from_wire(value: &str) -> Option { + Self::ALL.iter().copied().find(|v| v.wire() == value) + } +} + +/// `DiffColorScheme` (settings.ts:327); labels from `DiffColorsSettingsRow` (SettingsPanels.tsx:1275). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum DiffColors { + RedGreen, + BlueOrange, +} + +impl DiffColors { + pub const ALL: &'static [Self] = &[Self::RedGreen, Self::BlueOrange]; + + pub fn wire(self) -> &'static str { + match self { + Self::RedGreen => "red-green", + Self::BlueOrange => "blue-orange", + } + } + + /// The trigger shows the plain label. + pub fn label(self) -> &'static str { + match self { + Self::RedGreen => "Red & green", + Self::BlueOrange => "Blue & orange", + } + } + + /// The menu marks the default option (SettingsPanels.tsx:1281). + pub fn menu_label(self) -> &'static str { + match self { + Self::RedGreen => "Red & green (default)", + Self::BlueOrange => "Blue & orange", + } + } + + pub fn from_wire(value: &str) -> Option { + Self::ALL.iter().copied().find(|v| v.wire() == value) + } + + /// The two dots before the label, left to right, as sRGB (SettingsPanels.tsx:1264-1273). + /// The row paints deletion then addition; blue and orange reverse it (`flex-row-reverse`). + /// Red and green are `--diff-deletion` (`--error`: red-500, in dark mixed 90% into white) and + /// `--diff-addition` (`--success`: emerald-500); blue and orange are `blue-600/400` and + /// `orange-600/400` (index.css:2274, 2291-2292). + pub fn dots(self, dark: bool) -> [u32; 2] { + match (self, dark) { + (Self::RedGreen, false) => [0xef4444, 0x10b981], + (Self::RedGreen, true) => [0xf15757, 0x10b981], + (Self::BlueOrange, false) => [0x2563eb, 0xea580c], + (Self::BlueOrange, true) => [0x60a5fa, 0xfb923c], + } + } +} + +/// `diffFilesCollapsed` is a boolean on the wire; the select shows it as two states +/// (SettingsPanels.tsx:1372). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FileState { + Expanded, + Collapsed, +} + +impl FileState { + pub const ALL: &'static [Self] = &[Self::Expanded, Self::Collapsed]; + + pub fn label(self) -> &'static str { + match self { + Self::Expanded => "Expanded", + Self::Collapsed => "Collapsed", + } + } + + pub fn collapsed(self) -> bool { + self == Self::Collapsed + } + + pub fn of(collapsed: bool) -> Self { + if collapsed { + Self::Collapsed + } else { + Self::Expanded + } + } +} + +/// The Diffs & pull requests tab's settings, with `DEFAULT_CLIENT_SETTINGS` defaults. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DiffSettings { + /// `diffLayout`, default `stacked` (settings.ts:424). + pub layout: DiffLayout, + /// `diffIgnoreWhitespace`, default on (settings.ts:423). + pub ignore_whitespace: bool, + /// `diffFilesCollapsed`, default on (settings.ts:422). + pub files_collapsed: bool, + /// `diffColorScheme`, default `red-green` (settings.ts:358). + pub colors: DiffColors, + /// `proactivePanelsEnabled`, default off (settings.ts:477). + pub proactive_panels: bool, + /// `pullRequestLinksOpenInApp`, default on (settings.ts:466). + pub pr_links_in_app: bool, +} + +impl Default for DiffSettings { + fn default() -> Self { + Self { + layout: DiffLayout::Stacked, + ignore_whitespace: true, + files_collapsed: true, + colors: DiffColors::RedGreen, + proactive_panels: false, + pr_links_in_app: true, + } + } +} + +/// The document is bare or wrapped as `{"settings": {...}}` (`decodeClientSettingsJson`). +fn fields(value: &Value) -> Option<&Map> { + let object = value.as_object()?; + match object.get("settings") { + Some(Value::Object(inner)) => Some(inner), + _ => Some(object), + } +} + +impl DiffSettings { + /// A missing or invalid key falls back to its default; anything but an object is rejected. + pub fn parse(text: &str) -> Option { + let value: Value = serde_json::from_str(text).ok()?; + let map = fields(&value)?; + let d = Self::default(); + let flag = + |key: &str, fallback: bool| map.get(key).and_then(Value::as_bool).unwrap_or(fallback); + let choice = |key: &str| map.get(key).and_then(Value::as_str); + Some(Self { + layout: choice("diffLayout") + .and_then(DiffLayout::from_wire) + .unwrap_or(d.layout), + ignore_whitespace: flag("diffIgnoreWhitespace", d.ignore_whitespace), + files_collapsed: flag("diffFilesCollapsed", d.files_collapsed), + colors: choice("diffColorScheme") + .and_then(DiffColors::from_wire) + .unwrap_or(d.colors), + proactive_panels: flag("proactivePanelsEnabled", d.proactive_panels), + pr_links_in_app: flag("pullRequestLinksOpenInApp", d.pr_links_in_app), + }) + } + + pub fn entries(self) -> [(&'static str, Value); 6] { + [ + ("diffLayout", json!(self.layout.wire())), + ("diffIgnoreWhitespace", json!(self.ignore_whitespace)), + ("diffFilesCollapsed", json!(self.files_collapsed)), + ("diffColorScheme", json!(self.colors.wire())), + ("proactivePanelsEnabled", json!(self.proactive_panels)), + ("pullRequestLinksOpenInApp", json!(self.pr_links_in_app)), + ] + } + + /// Writes these keys into `existing`, keeping every key this tab does not own. + pub fn to_json(self, existing: Option<&str>) -> String { + let mut document = existing + .and_then(|text| serde_json::from_str::(text).ok()) + .filter(Value::is_object) + .unwrap_or_else(|| json!({})); + let target = match document.get_mut("settings") { + Some(inner) if inner.is_object() => inner, + _ => &mut document, + }; + if let Some(map) = target.as_object_mut() { + for (key, value) in self.entries() { + map.insert(key.to_owned(), value); + } + } + document.to_string() + } + + fn path() -> Option { + crate::settings::DeviceSettings::path() + } + + pub fn load() -> Self { + Self::path() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|text| Self::parse(&text)) + .unwrap_or_default() + } + + /// Re-reads the file first, so keys the General tab saved in the meantime survive. + pub fn save(self) { + if let Some(path) = Self::path() { + let existing = std::fs::read_to_string(&path).ok(); + let _ = std::fs::write(path, self.to_json(existing.as_deref())); + } + } + + /// The labels `useSettingsRestore` lists (SettingsPanels.tsx:451, 511-520), keyed by where + /// they sit among the other tabs' labels: `colors` comes first, the other three after the + /// General tab's sidebar labels. `pr_links_in_app` is restored but never listed there. + fn changed(self) -> (Option<&'static str>, Vec<&'static str>) { + let d = Self::default(); + let colors = (self.colors != d.colors).then_some("Diff colors"); + let rest = [ + ( + self.files_collapsed != d.files_collapsed, + "Default diff file state", + ), + ( + self.ignore_whitespace != d.ignore_whitespace, + "Diff whitespace changes", + ), + (self.layout != d.layout, "Diff layout"), + ( + self.proactive_panels != d.proactive_panels, + "Open pull requests and changes panels proactively", + ), + ] + .into_iter() + .filter_map(|(changed, label)| changed.then_some(label)) + .collect(); + (colors, rest) + } + + /// Interleaves these labels into the General tab's list in the web's order: `Diff colors` + /// first, the rest before the first Chat or confirmation label. + pub fn merge_labels(self, general: Vec<&'static str>) -> Vec<&'static str> { + const CONFIRMATIONS: [&str; 8] = [ + "Show skills in slash menu", + "Rich text composer", + "Send shortcut", + "Follow-up behavior", + "Unpin confirmation", + "Archive confirmation", + "Delete confirmation", + "Quit shortcut", + ]; + let (colors, rest) = self.changed(); + let split = general + .iter() + .position(|label| CONFIRMATIONS.contains(label)) + .unwrap_or(general.len()); + let mut merged = Vec::with_capacity(general.len() + rest.len() + 1); + merged.extend(colors); + merged.extend_from_slice(&general[..split]); + merged.extend(rest); + merged.extend_from_slice(&general[split..]); + merged + } +} + +const fn item(id: &'static str, title: &'static str, terms: &'static str) -> SearchItem { + SearchItem { + id, + title, + section: Section::Diffs, + terms, + desktop_only: false, + } +} + +/// The Diffs & pull requests tab's `searchableSetting` entries (settingsSearch.ts:327, 556-577, +/// 934), in the web's order. The host names in the last entry are the built-in source control +/// definitions' labels, in registry order. +pub const DIFFS_ITEMS: &[SearchItem] = &[ + item( + "diff-color-scheme", + "Diff colors", + "red green blue orange additions deletions changes counts palette colorblind", + ), + item( + "hide-whitespace-changes", + "Hide whitespace changes", + "diff ignore spaces edits default", + ), + item( + "default-diff-file-state", + "Default diff file state", + "collapsed expanded collapse expand files pull request pr code tab", + ), + item( + "diff-layout", + "Diff layout", + "stacked split side by side unified inline view", + ), + item( + "proactive-panels", + "Open pull requests and changes panels proactively", + "proactive automatically open diff pull request pr right panel agent completion", + ), + item( + "pull-request-links-open-in-app", + "Open pull requests in T3 Code", + "pr commit links GitHub GitLab GitCafe Forgejo Bitbucket Azure DevOps browser external click default pull request view panel", + ), +]; + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::{search, searchable}; + + #[test] + fn defaults_match_the_contract() { + let d = DiffSettings::default(); + assert_eq!(d.layout, DiffLayout::Stacked); + assert!(d.ignore_whitespace && d.files_collapsed && d.pr_links_in_app); + assert!(!d.proactive_panels); + assert_eq!(d.colors, DiffColors::RedGreen); + } + + #[test] + fn parse_reads_bare_and_wrapped_documents() { + let text = r#"{"settings":{"diffLayout":"split","diffIgnoreWhitespace":false, + "diffFilesCollapsed":false,"diffColorScheme":"blue-orange", + "proactivePanelsEnabled":true,"pullRequestLinksOpenInApp":false}}"#; + let parsed = DiffSettings::parse(text).unwrap(); + assert_eq!(parsed.layout, DiffLayout::Split); + assert!(!parsed.ignore_whitespace && !parsed.files_collapsed && !parsed.pr_links_in_app); + assert_eq!(parsed.colors, DiffColors::BlueOrange); + assert!(parsed.proactive_panels); + assert_eq!( + DiffSettings::parse(r#"{"diffLayout":"split"}"#) + .unwrap() + .layout, + DiffLayout::Split + ); + } + + #[test] + fn parse_falls_back_per_key_and_rejects_non_objects() { + let parsed = DiffSettings::parse(r#"{"diffLayout":"bogus","diffColorScheme":3}"#).unwrap(); + assert_eq!(parsed, DiffSettings::default()); + assert!(DiffSettings::parse("[]").is_none()); + assert!(DiffSettings::parse("not json").is_none()); + } + + #[test] + fn to_json_keeps_foreign_keys_in_either_shape() { + let changed = DiffSettings { + layout: DiffLayout::Split, + ..DiffSettings::default() + }; + let wrapped: Value = serde_json::from_str( + &changed.to_json(Some(r#"{"settings":{"timestampFormat":"24-hour"}}"#)), + ) + .unwrap(); + assert_eq!(wrapped["settings"]["timestampFormat"], "24-hour"); + assert_eq!(wrapped["settings"]["diffLayout"], "split"); + let bare: Value = + serde_json::from_str(&changed.to_json(Some(r#"{"chatWidth":"wide"}"#))).unwrap(); + assert_eq!(bare["chatWidth"], "wide"); + assert_eq!(bare["diffColorScheme"], "red-green"); + assert_eq!(DiffSettings::parse(&changed.to_json(None)), Some(changed)); + } + + #[test] + fn menu_marks_only_the_default_colors() { + assert_eq!(DiffColors::RedGreen.menu_label(), "Red & green (default)"); + assert_eq!(DiffColors::RedGreen.label(), "Red & green"); + assert_eq!(DiffColors::BlueOrange.menu_label(), "Blue & orange"); + } + + #[test] + fn dots_follow_the_row_order() { + // Red then green; blue (addition) then orange (deletion), reversed by `flex-row-reverse`. + assert_eq!(DiffColors::RedGreen.dots(false), [0xef4444, 0x10b981]); + assert_eq!(DiffColors::BlueOrange.dots(true), [0x60a5fa, 0xfb923c]); + } + + #[test] + fn file_state_round_trips_the_boolean() { + assert_eq!(FileState::of(true), FileState::Collapsed); + assert!(!FileState::Expanded.collapsed()); + } + + #[test] + fn labels_interleave_in_the_web_order() { + let all = DiffSettings { + layout: DiffLayout::Split, + ignore_whitespace: false, + files_collapsed: false, + colors: DiffColors::BlueOrange, + proactive_panels: true, + pr_links_in_app: false, + }; + let general = vec![ + "Time format", + "Working section", + "Unpin confirmation", + "Quit shortcut", + ]; + assert_eq!( + all.merge_labels(general), + vec![ + "Diff colors", + "Time format", + "Working section", + "Default diff file state", + "Diff whitespace changes", + "Diff layout", + "Open pull requests and changes panels proactively", + "Unpin confirmation", + "Quit shortcut", + ] + ); + assert_eq!( + DiffSettings::default().merge_labels(vec!["Time format"]), + vec!["Time format"] + ); + } + + #[test] + fn the_links_setting_is_restored_but_never_listed() { + let only_links = DiffSettings { + pr_links_in_app: false, + ..DiffSettings::default() + }; + assert!(only_links.merge_labels(Vec::new()).is_empty()); + assert!(DiffSettings::default().pr_links_in_app); + } + + #[test] + fn search_finds_each_setting_in_the_diffs_section() { + let hit = |query: &str| search(query, DIFFS_ITEMS).first().map(|i| i.id); + assert_eq!(hit("colorblind"), Some("diff-color-scheme")); + assert_eq!(hit("side by side"), Some("diff-layout")); + assert_eq!(hit("whitespace"), Some("hide-whitespace-changes")); + assert_eq!(hit("gitlab"), Some("pull-request-links-open-in-app")); + assert!(DIFFS_ITEMS.iter().all(|i| i.section == Section::Diffs)); + assert_eq!( + search("diff layout", searchable()) + .first() + .map(|i| i.section), + Some(Section::Diffs) + ); + } +} diff --git a/apps/desktop-gpui/src/settings_labs.rs b/apps/desktop-gpui/src/settings_labs.rs new file mode 100644 index 000000000000..8c3df33df6a3 --- /dev/null +++ b/apps/desktop-gpui/src/settings_labs.rs @@ -0,0 +1,1102 @@ +//! The Labs tab (`LabsSettingsPanel.tsx`, `LegacyFeaturesSection` in `SettingsPanels.tsx`): its +//! device settings, the settings scope bar's model (`settingsScope.ts`, `settingsScopeAxis.ts`), +//! the copy links and the search entries. The panel and the pickers only draw this. +//! +//! Where each Labs key lives (packages/contracts/src/settings.ts): +//! - Device settings, `client-settings.json` (`ClientSettingsSchema`): `boringSidebarEnabled`, +//! `boringSidebarMultilineRows`, `toolCallVisibility`, `legacySidebarEnabled` (hidden, cleared +//! by the boring sidebar switch), `planModeEnabled`, `contextWindowMeterEnabled`. A device +//! setting ignores the scope bar. +//! - Server settings, environment-wide: `guestSharingEnabled`, `linkedEnvironmentsEnabled`, +//! `simlockDeviceHostsEnabled`. None is in `PROJECT_SCOPED_SERVER_SETTING_KEYS`, so a project +//! scope cannot write them. + +use crate::projects::{COLORS, MachineKind}; +use crate::settings::{SearchItem, Section}; +use serde_json::{Map, Value, json}; +use std::path::PathBuf; + +/// Call tokens of the Labs tab's server writes start here. +pub const TOKEN_BASE: u64 = 1 << 44; + +/// `ToolCallVisibility` (settings.ts:346); labels from `LabsSettingsPanel.tsx`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ToolCalls { + ShowAll, + HideSuccessful, + HideAll, + Zen, +} + +impl ToolCalls { + pub const ALL: &'static [Self] = &[ + Self::ShowAll, + Self::HideSuccessful, + Self::HideAll, + Self::Zen, + ]; + + pub fn wire(self) -> &'static str { + match self { + Self::ShowAll => "showAll", + Self::HideSuccessful => "hideSuccessful", + Self::HideAll => "hideAll", + Self::Zen => "zen", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::ShowAll => "Show all", + Self::HideSuccessful => "Hide successful", + Self::HideAll => "Hide all", + Self::Zen => "Zen", + } + } + + pub fn from_wire(value: &str) -> Option { + Self::ALL.iter().copied().find(|v| v.wire() == value) + } +} + +/// The Labs keys of `ClientSettingsSchema`, with the schema's defaults. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct LabsSettings { + pub boring_sidebar: bool, + pub boring_multiline: bool, + pub tool_calls: ToolCallsDefault, + pub legacy_sidebar: bool, + pub plan_mode: bool, + pub context_meter: bool, +} + +/// `ToolCalls` with `showAll` as its `Default`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ToolCallsDefault(pub ToolCalls); + +impl Default for ToolCallsDefault { + fn default() -> Self { + Self(ToolCalls::ShowAll) + } +} + +/// The document is bare or wrapped as `{"settings": {...}}` (`decodeClientSettingsJson`). +fn fields(value: &Value) -> Option<&Map> { + let object = value.as_object()?; + match object.get("settings") { + Some(Value::Object(inner)) => Some(inner), + _ => Some(object), + } +} + +impl LabsSettings { + /// `boringSidebarEnabled || legacySidebarEnabled`: the Boring sidebar switch's state. + pub fn boring_checked(self) -> bool { + self.boring_sidebar || self.legacy_sidebar + } + + /// The switch writes `{boringSidebarEnabled: checked, legacySidebarEnabled: false}`. + pub fn set_boring(&mut self, checked: bool) { + self.boring_sidebar = checked; + self.legacy_sidebar = false; + } + + pub fn parse(text: &str) -> Option { + let value: Value = serde_json::from_str(text).ok()?; + let map = fields(&value)?; + let flag = |key: &str| map.get(key).and_then(Value::as_bool).unwrap_or(false); + Some(Self { + boring_sidebar: flag("boringSidebarEnabled"), + boring_multiline: flag("boringSidebarMultilineRows"), + tool_calls: ToolCallsDefault( + map.get("toolCallVisibility") + .and_then(Value::as_str) + .and_then(ToolCalls::from_wire) + .unwrap_or(ToolCalls::ShowAll), + ), + legacy_sidebar: flag("legacySidebarEnabled"), + plan_mode: flag("planModeEnabled"), + context_meter: flag("contextWindowMeterEnabled"), + }) + } + + pub fn entries(self) -> [(&'static str, Value); 6] { + [ + ("boringSidebarEnabled", json!(self.boring_sidebar)), + ("boringSidebarMultilineRows", json!(self.boring_multiline)), + ("toolCallVisibility", json!(self.tool_calls.0.wire())), + ("legacySidebarEnabled", json!(self.legacy_sidebar)), + ("planModeEnabled", json!(self.plan_mode)), + ("contextWindowMeterEnabled", json!(self.context_meter)), + ] + } + + /// Writes these keys into `existing`, keeping every key this tab does not own. + pub fn to_json(self, existing: Option<&str>) -> String { + let mut document = existing + .and_then(|text| serde_json::from_str::(text).ok()) + .filter(Value::is_object) + .unwrap_or_else(|| json!({})); + let target = match document.get_mut("settings") { + Some(inner) if inner.is_object() => inner, + _ => &mut document, + }; + if let Some(map) = target.as_object_mut() { + for (key, value) in self.entries() { + map.insert(key.to_owned(), value); + } + } + document.to_string() + } + + fn path() -> Option { + crate::settings::DeviceSettings::path() + } + + pub fn load() -> Self { + Self::path() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|text| Self::parse(&text)) + .unwrap_or_default() + } + + /// Re-reads the file first, so keys the other tabs saved in the meantime survive. + pub fn save(self) { + if let Some(path) = Self::path() { + let existing = std::fs::read_to_string(&path).ok(); + let _ = std::fs::write(path, self.to_json(existing.as_deref())); + } + } +} + +/// The three environment-wide server settings of the tab. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ServerKey { + GuestSharing, + LinkedEnvironments, + SimlockHosts, +} + +impl ServerKey { + #[cfg(test)] + pub const ALL: [Self; 3] = [ + Self::GuestSharing, + Self::LinkedEnvironments, + Self::SimlockHosts, + ]; + + pub fn wire(self) -> &'static str { + match self { + Self::GuestSharing => "guestSharingEnabled", + Self::LinkedEnvironments => "linkedEnvironmentsEnabled", + Self::SimlockHosts => "simlockDeviceHostsEnabled", + } + } + + /// The `settings.update` payload: `{"patch": {key: enabled}}`. + pub fn patch(self, enabled: bool) -> Value { + json!({ "patch": { self.wire(): enabled } }) + } + + /// Nothing is project-scoped here, so a project scope can never write it. + pub fn project_scoped(self) -> bool { + false + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ServerFlags { + pub guest_sharing: bool, + pub linked_environments: bool, + pub simlock_hosts: bool, +} + +impl ServerFlags { + pub fn get(self, key: ServerKey) -> bool { + match key { + ServerKey::GuestSharing => self.guest_sharing, + ServerKey::LinkedEnvironments => self.linked_environments, + ServerKey::SimlockHosts => self.simlock_hosts, + } + } + + pub fn set(&mut self, key: ServerKey, enabled: bool) { + match key { + ServerKey::GuestSharing => self.guest_sharing = enabled, + ServerKey::LinkedEnvironments => self.linked_environments = enabled, + ServerKey::SimlockHosts => self.simlock_hosts = enabled, + } + } +} + +/// Where an inline link in a row description goes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Link { + /// Another settings section: the modal switches to it. + Section(Section), + /// Opens in the browser. + External(&'static str), +} + +/// A run of a row description: plain text or a link (`underline underline-offset-2`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Piece { + Text(&'static str), + Link(&'static str, Link), +} + +/// `LabsSettingsPanel.tsx:155-162`. `/settings/access` is "Sharing & access". +pub const GUEST_LINKS: &[Piece] = &[ + Piece::Text( + "Share a thread with a guest who watches it live in a browser and sends messages you approve. Needs T3 Connect: sign in and link this machine in ", + ), + Piece::Link("Sharing & access", Link::Section(Section::Access)), + Piece::Text(". Turning it off stops every guest link."), +]; + +/// `LabsSettingsPanel.tsx:182-189`. +pub const LINKED_ENVIRONMENTS: &[Piece] = &[ + Piece::Text( + "Let agents here work on your other machines, and theirs here. Turn it on for each machine, then link them in ", + ), + Piece::Link("Connections", Link::Section(Section::Connections)), + Piece::Text(". While off, nothing new starts over a link; running tasks finish."), +]; + +/// `LabsSettingsPanel.tsx:208-225`. +pub const SIMLOCK_HOSTS: &[Piece] = &[ + Piece::Text("Let the Device panel lease simulators and emulators from your own "), + Piece::Link( + "Simlock", + Link::External("https://incubator.callstack.com/simlock/"), + ), + Piece::Text(" pool, added in "), + Piece::Link("Devices", Link::Section(Section::Devices)), + Piece::Text(". An alpha that may change or break. T3 Code never installs Simlock."), +]; + +const fn item(id: &'static str, title: &'static str, terms: &'static str) -> SearchItem { + SearchItem { + id, + title, + section: Section::Labs, + terms, + desktop_only: false, + } +} + +/// The Labs tab's `searchableSetting` entries (settingsSearch.ts:458-497, 863-873). +pub const LABS_ITEMS: &[SearchItem] = &[ + item( + "boring-sidebar", + "Boring sidebar (group by project)", + "beta folders project tree legacy old classic sidebar", + ), + item( + "boring-sidebar-multiline", + "Two-line folder rows", + "beta boring sidebar folder rows multiline branch pull request details", + ), + item( + "tool-call-visibility", + "Tool calls in chat", + "beta hide tool calls commands edits searches work log activity quiet chat zen final answers only", + ), + item( + "guest-links", + "Guest links", + "beta share guest invite link teammate watch t3 connect tunnel", + ), + item( + "linked-environments-beta", + "Linked environments", + "beta link another machine vps peer agents work there delegate", + ), + item( + "simlock-hosts", + "Simlock device hosts (alpha)", + "beta alpha simlock callstack simulator emulator ios android pool lease gateway", + ), + item( + "legacy-plan-mode", + "Plan mode (legacy)", + "build plan composer old", + ), + item( + "legacy-context-window-indicator", + "Context window indicator (legacy)", + "composer meter usage tokens circle old", + ), +]; + +/// The id the scope pickers give their "All ..." entry (`ALL_PROJECTS_VALUE`). +pub const ALL: &str = "all"; + +/// `SettingsScopeSearch` without the checkout axis, which this app has no picker for. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ScopeSearch { + pub project: Option, + pub machine: Option, +} + +impl ScopeSearch { + /// `selectEnvironmentAxis`: keeps the project. + pub fn select_environment(&self, value: &str) -> Self { + Self { + project: self.project.clone(), + machine: (value != ALL).then(|| value.to_owned()), + } + } + + /// `selectProjectAxis`: keeps the environment. + pub fn select_project(&self, value: &str) -> Self { + Self { + project: (value != ALL).then(|| value.to_owned()), + machine: self.machine.clone(), + } + } + + /// The sentence's middle word (SettingsScopeSentence.tsx:84). + pub fn joiner(&self) -> &'static str { + if self.machine.is_some() { + "on" + } else { + "across" + } + } +} + +/// An environment as the scope bar sees it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Environment { + pub id: String, + pub label: String, + /// `host:port`, else the environment id (`settingsScopeEnvironmentAddress`). + pub address: String, + pub machine: MachineKind, + pub connected: bool, + /// Whether this app holds the connection that can write its server settings. + pub writable: bool, +} + +/// A project group as the scope bar sees it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Project { + pub key: String, + pub name: String, + pub root: String, + pub initials: String, + /// Index into the shared palette. + pub color: usize, + pub archived: bool, + /// The environments with a member of the group. + pub environments: Vec, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ScopeData { + pub environments: Vec, + pub projects: Vec, +} + +/// `ResolvedSettingsScope`, reduced to what the tab reads. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Scope { + All, + Environment(String), + /// A project, on the listed environments. + Project(Vec), + Unavailable(&'static str), +} + +pub fn resolve(search: &ScopeSearch, data: &ScopeData) -> Scope { + let environment = search + .machine + .as_ref() + .map(|id| data.environments.iter().any(|e| &e.id == id)); + if environment == Some(false) { + return Scope::Unavailable("This environment is no longer available."); + } + if let Some(key) = &search.project { + let Some(project) = data.projects.iter().find(|p| &p.key == key) else { + return Scope::Unavailable("This project is no longer available."); + }; + let members: Vec = project + .environments + .iter() + .filter(|id| search.machine.as_ref().is_none_or(|machine| machine == *id)) + .cloned() + .collect(); + if members.is_empty() { + return Scope::Unavailable("This project has no checkout on this environment."); + } + return Scope::Project(members); + } + match &search.machine { + Some(id) => Scope::Environment(id.clone()), + None => Scope::All, + } +} + +/// Why a server row's control is inert, or the environments its write reaches. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Reach { + Targets(Vec), + Inert(String), +} + +/// `planScopedSettingsPatch` for one environment-wide server key: all and environment scopes +/// write every writable connected environment in scope; a project scope cannot write it +/// (settingsLayout.tsx:448); an unavailable scope shows its message. +pub fn reach(key: ServerKey, search: &ScopeSearch, data: &ScopeData) -> Reach { + let writable = |ids: &[String]| -> Vec { + ids.iter() + .filter(|id| { + data.environments + .iter() + .any(|e| &e.id == *id && e.connected && e.writable) + }) + .cloned() + .collect() + }; + match resolve(search, data) { + Scope::Unavailable(message) => Reach::Inert(message.to_owned()), + Scope::Project(_) if !key.project_scoped() => { + Reach::Inert("Environment-wide setting. Select an environment to change it.".to_owned()) + } + Scope::Project(ids) => targets_or_inert(writable(&ids)), + Scope::Environment(id) => match data.environments.iter().find(|e| e.id == id) { + Some(e) if !e.connected => Reach::Inert( + "Reconnect the selected environment to change this setting.".to_owned(), + ), + Some(e) if !e.writable => Reach::Inert(format!( + "Settings of {} cannot be changed from this app yet.", + e.label + )), + _ => targets_or_inert(writable(&[id])), + }, + Scope::All => { + let ids: Vec = data.environments.iter().map(|e| e.id.clone()).collect(); + targets_or_inert(writable(&ids)) + } + } +} + +fn targets_or_inert(targets: Vec) -> Reach { + if targets.is_empty() { + Reach::Inert("Reconnect the selected environment to change this setting.".to_owned()) + } else { + Reach::Targets(targets) + } +} + +/// One picker row (`ScopeOption`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Choice { + pub value: String, + pub label: String, + pub secondary: String, + pub offline: bool, + pub icon: ChoiceIcon, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ChoiceIcon { + None, + Layers, + Machine(MachineKind), + Project { initials: String, color: usize }, +} + +/// `settingsScopeEnvironmentLabels`: the secondary text only for a label another environment +/// shares. +fn environment_secondary(environment: &Environment, all: &[Environment]) -> String { + let duplicates: Vec<&Environment> = all + .iter() + .filter(|other| other.id != environment.id && other.label == environment.label) + .collect(); + if duplicates.is_empty() { + return String::new(); + } + environment.address.clone() +} + +/// `settingsScopeEnvironmentLabel`: `label · secondary` when the label is shared. +pub fn environment_label(environment: &Environment, all: &[Environment]) -> String { + let secondary = environment_secondary(environment, all); + if secondary.is_empty() { + environment.label.clone() + } else { + format!("{} · {secondary}", environment.label) + } +} + +/// The project picker's rows after the "All projects" entry (`ProjectScopeMenu`, +/// `projectScopeMenuGroups`). +pub fn project_choices(search: &ScopeSearch, data: &ScopeData) -> Vec { + let shown: Vec<&Project> = data + .projects + .iter() + .filter(|project| { + search + .machine + .as_ref() + .is_none_or(|machine| project.environments.contains(machine)) + }) + .collect(); + let machines = |project: &Project| -> String { + let mut names: Vec = Vec::new(); + for id in &project.environments { + let name = data.environments.iter().find(|e| &e.id == id).map_or_else( + || id.clone(), + |e| format!("{} \u{b7} {}", e.label, e.address), + ); + if !names.contains(&name) { + names.push(name); + } + } + names.join(", ") + }; + shown + .iter() + .map(|project| { + let same_name: Vec<&&Project> = shown + .iter() + .filter(|other| other.key != project.key && other.name == project.name) + .collect(); + let location = if same_name.is_empty() { + if data.environments.len() > 1 { + project.root.clone() + } else { + String::new() + } + } else if same_name + .iter() + .any(|other| machines(other) == machines(project)) + { + format!("{} \u{b7} {}", machines(project), project.root) + } else { + machines(project) + }; + let secondary = if project.archived { + ["Archived", location.as_str()] + .iter() + .filter(|part| !part.is_empty()) + .copied() + .collect::>() + .join(" \u{b7} ") + } else { + location + }; + Choice { + value: project.key.clone(), + label: project.name.clone(), + secondary, + offline: false, + icon: ChoiceIcon::Project { + initials: project.initials.clone(), + color: project.color, + }, + } + }) + .collect() +} + +/// The environment picker's rows after "All environments" (`EnvironmentScopeMenu`): narrowed to +/// the selected project's environments. +pub fn environment_choices(search: &ScopeSearch, data: &ScopeData) -> Vec { + let members = search + .project + .as_ref() + .and_then(|key| data.projects.iter().find(|p| &p.key == key)) + .map(|project| &project.environments); + data.environments + .iter() + .filter(|e| members.is_none_or(|ids| ids.contains(&e.id))) + .map(|e| Choice { + value: e.id.clone(), + label: e.label.clone(), + secondary: if data.environments.len() <= 1 { + String::new() + } else { + let secondary = environment_secondary(e, &data.environments); + if secondary.is_empty() { + e.address.clone() + } else { + secondary + } + }, + offline: !e.connected, + icon: ChoiceIcon::Machine(e.machine), + }) + .collect() +} + +/// The project picker's trigger label. +pub fn project_label(search: &ScopeSearch, data: &ScopeData) -> String { + match &search.project { + None => "All projects".to_owned(), + Some(key) => data + .projects + .iter() + .find(|p| &p.key == key) + .map_or_else(|| "Unavailable project".to_owned(), |p| p.name.clone()), + } +} + +/// The environment picker's trigger label. +pub fn environment_trigger_label(search: &ScopeSearch, data: &ScopeData) -> String { + match &search.machine { + None => "All environments".to_owned(), + Some(id) => data.environments.iter().find(|e| &e.id == id).map_or_else( + || "Unavailable environment".to_owned(), + |e| environment_label(e, &data.environments), + ), + } +} + +/// A picker opens a search field past six options (`options.length > 6`). +pub fn searchable(options: usize) -> bool { + options > 6 +} + +/// The combobox filter: `label secondary value`, lowercased, contains the trimmed query. +pub fn filter<'a>(choices: &'a [Choice], query: &str) -> Vec<&'a Choice> { + let needle = query.trim().to_lowercase(); + choices + .iter() + .filter(|c| { + format!("{} {} {}", c.label, c.secondary, c.value) + .to_lowercase() + .contains(&needle) + }) + .collect() +} + +/// The tile's color: palette index to its `(light, dark)` Tailwind shades. +pub fn avatar_color(index: usize, dark: bool) -> u32 { + let (_, light, night) = COLORS[index % COLORS.len()]; + if dark { night } else { light } +} + +#[cfg(test)] +/// The Labs search entries that match `query`, in rank order. +pub fn search_hits(query: &str) -> Vec<&'static str> { + crate::settings::search(query, LABS_ITEMS) + .into_iter() + .map(|item| item.id) + .collect() +} + +/// The scope bar's data from what the status carries: the attached environment (the only one +/// this app can write) and the other saved ones, whose projects arrive without a connection. +pub fn scope_data( + primary_label: &str, + primary_machine: MachineKind, + primary: &[crate::projects::ProjectEntry], + others: &[crate::projects::EnvironmentProjects], +) -> ScopeData { + const PRIMARY: &str = "primary"; + let mut data = ScopeData::default(); + data.environments.push(Environment { + id: PRIMARY.to_owned(), + label: primary_label.to_owned(), + address: PRIMARY.to_owned(), + machine: primary_machine, + connected: true, + writable: true, + }); + let mut add = |env: &str, entries: &[crate::projects::ProjectEntry]| { + for entry in entries.iter().filter(|e| !e.scratch) { + let key = entry + .logical_key + .clone() + .unwrap_or_else(|| entry.key.clone()); + match data.projects.iter_mut().find(|p| p.key == key) { + Some(project) => { + if !project.environments.iter().any(|e| e == env) { + project.environments.push(env.to_owned()); + } + } + None => data.projects.push(Project { + key, + name: entry.title.clone(), + root: entry.root.clone(), + initials: entry.initials.clone(), + color: entry.color, + archived: false, + environments: vec![env.to_owned()], + }), + } + } + }; + add(PRIMARY, primary); + for other in others { + add(&other.id, &other.projects); + } + for other in others { + data.environments.push(Environment { + id: other.id.clone(), + label: other.label.clone(), + address: other.id.clone(), + machine: other.machine, + connected: !other.projects.is_empty(), + writable: false, + }); + } + data +} + +#[cfg(test)] +mod tests { + use super::*; + + fn env(id: &str, label: &str, connected: bool, writable: bool) -> Environment { + Environment { + id: id.to_owned(), + label: label.to_owned(), + address: format!("{id}.example:3773"), + machine: MachineKind::Laptop, + connected, + writable, + } + } + + fn project(key: &str, name: &str, environments: &[&str]) -> Project { + Project { + key: key.to_owned(), + name: name.to_owned(), + root: format!("/home/user/projects/{key}"), + initials: crate::projects::initials(name), + color: crate::projects::color_index(name), + archived: false, + environments: environments.iter().map(|s| (*s).to_owned()).collect(), + } + } + + fn data() -> ScopeData { + ScopeData { + environments: vec![ + env("a", "Example Mac", true, true), + env("b", "Example VPS", true, false), + env("c", "Example Box", false, false), + ], + projects: vec![ + project("one", "Example One", &["a", "b"]), + project("two", "Example Two", &["b"]), + ], + } + } + + fn search(project: Option<&str>, machine: Option<&str>) -> ScopeSearch { + ScopeSearch { + project: project.map(str::to_owned), + machine: machine.map(str::to_owned), + } + } + + #[test] + fn defaults_match_the_contract() { + let d = LabsSettings::default(); + assert!(!d.boring_sidebar && !d.boring_multiline && !d.legacy_sidebar); + assert!(!d.plan_mode && !d.context_meter); + assert_eq!(d.tool_calls.0, ToolCalls::ShowAll); + } + + #[test] + fn parses_bare_and_wrapped_documents_and_keeps_foreign_keys() { + let wrapped = LabsSettings::parse( + r#"{"settings":{"boringSidebarEnabled":true,"toolCallVisibility":"zen","theme":"dark"}}"#, + ) + .unwrap(); + assert!(wrapped.boring_sidebar); + assert_eq!(wrapped.tool_calls.0, ToolCalls::Zen); + let bad = LabsSettings::parse(r#"{"toolCallVisibility":"loud"}"#).unwrap(); + assert_eq!(bad.tool_calls.0, ToolCalls::ShowAll); + assert!(LabsSettings::parse("[]").is_none()); + let written = LabsSettings::default().to_json(Some(r#"{"settings":{"theme":"dark"}}"#)); + let value: Value = serde_json::from_str(&written).unwrap(); + assert_eq!(value["settings"]["theme"], "dark"); + assert_eq!(value["settings"]["toolCallVisibility"], "showAll"); + } + + #[test] + fn the_boring_switch_reads_either_flag_and_clears_the_legacy_one() { + let mut s = LabsSettings { + legacy_sidebar: true, + ..LabsSettings::default() + }; + assert!(s.boring_checked()); + s.set_boring(false); + assert!(!s.boring_checked() && !s.legacy_sidebar); + s.set_boring(true); + assert!(s.boring_sidebar && !s.legacy_sidebar); + } + + #[test] + fn tool_call_labels_follow_the_web_order() { + let labels: Vec<_> = ToolCalls::ALL.iter().map(|v| v.label()).collect(); + assert_eq!(labels, ["Show all", "Hide successful", "Hide all", "Zen"]); + } + + #[test] + fn scope_resolves_like_the_web() { + let d = data(); + assert_eq!(resolve(&search(None, None), &d), Scope::All); + assert_eq!( + resolve(&search(None, Some("b")), &d), + Scope::Environment("b".into()) + ); + assert_eq!( + resolve(&search(Some("one"), None), &d), + Scope::Project(vec!["a".into(), "b".into()]) + ); + assert_eq!( + resolve(&search(Some("one"), Some("b")), &d), + Scope::Project(vec!["b".into()]) + ); + assert!(matches!( + resolve(&search(Some("two"), Some("a")), &d), + Scope::Unavailable("This project has no checkout on this environment.") + )); + assert!(matches!( + resolve(&search(Some("gone"), None), &d), + Scope::Unavailable("This project is no longer available.") + )); + assert!(matches!( + resolve(&search(None, Some("gone")), &d), + Scope::Unavailable("This environment is no longer available.") + )); + } + + #[test] + fn axis_selection_keeps_the_other_axis() { + let s = search(Some("one"), Some("a")); + assert_eq!(s.select_project(ALL), search(None, Some("a"))); + assert_eq!(s.select_environment(ALL), search(Some("one"), None)); + assert_eq!(s.select_environment("b"), search(Some("one"), Some("b"))); + assert_eq!(s.joiner(), "on"); + assert_eq!(search(Some("one"), None).joiner(), "across"); + } + + #[test] + fn server_rows_write_only_writable_connected_environments() { + let d = data(); + let key = ServerKey::GuestSharing; + assert_eq!( + reach(key, &search(None, None), &d), + Reach::Targets(vec!["a".into()]) + ); + assert_eq!( + reach(key, &search(None, Some("a")), &d), + Reach::Targets(vec!["a".into()]) + ); + assert!( + matches!(reach(key, &search(None, Some("c")), &d), Reach::Inert(m) if m.starts_with("Reconnect")) + ); + assert!( + matches!(reach(key, &search(None, Some("b")), &d), Reach::Inert(m) if m.contains("Example VPS")) + ); + assert_eq!( + reach(key, &search(Some("one"), None), &d), + Reach::Inert("Environment-wide setting. Select an environment to change it.".into()) + ); + assert_eq!( + reach(key, &search(Some("gone"), None), &d), + Reach::Inert("This project is no longer available.".into()) + ); + } + + #[test] + fn server_patches_use_the_contract_keys() { + assert_eq!( + ServerKey::SimlockHosts.patch(true), + json!({"patch": {"simlockDeviceHostsEnabled": true}}) + ); + assert!(ServerKey::ALL.iter().all(|k| !k.project_scoped())); + let mut flags = ServerFlags::default(); + flags.set(ServerKey::LinkedEnvironments, true); + assert!(flags.get(ServerKey::LinkedEnvironments) && !flags.get(ServerKey::GuestSharing)); + } + + #[test] + fn project_rows_hide_paths_for_one_environment_and_narrow_by_machine() { + let mut d = data(); + let rows = project_choices(&search(None, None), &d); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].secondary, "/home/user/projects/one"); + assert_eq!(project_choices(&search(None, Some("a")), &d).len(), 1); + d.environments.truncate(1); + assert_eq!(project_choices(&search(None, None), &d)[0].secondary, ""); + } + + #[test] + fn same_named_projects_are_told_apart_by_machine() { + let mut d = data(); + d.projects = vec![project("x1", "Same", &["a"]), project("x2", "Same", &["b"])]; + let rows = project_choices(&search(None, None), &d); + assert_eq!(rows[0].secondary, "Example Mac \u{b7} a.example:3773"); + d.projects[1].environments = vec!["a".into()]; + let rows = project_choices(&search(None, None), &d); + assert!(rows[0].secondary.ends_with("/home/user/projects/x1")); + d.projects[0].archived = true; + assert!( + project_choices(&search(None, None), &d)[0] + .secondary + .starts_with("Archived \u{b7} ") + ); + } + + #[test] + fn environment_rows_narrow_to_the_project_and_flag_offline() { + let d = data(); + let rows = environment_choices(&search(Some("two"), None), &d); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].value, "b"); + let all = environment_choices(&search(None, None), &d); + assert_eq!(all.len(), 3); + assert!(all[2].offline && !all[0].offline); + assert_eq!(all[0].secondary, "a.example:3773"); + } + + #[test] + fn trigger_labels_cover_the_stale_cases() { + let d = data(); + assert_eq!(project_label(&search(None, None), &d), "All projects"); + assert_eq!(project_label(&search(Some("one"), None), &d), "Example One"); + assert_eq!( + project_label(&search(Some("gone"), None), &d), + "Unavailable project" + ); + assert_eq!( + environment_trigger_label(&search(None, None), &d), + "All environments" + ); + assert_eq!( + environment_trigger_label(&search(None, Some("gone")), &d), + "Unavailable environment" + ); + let mut dup = data(); + dup.environments[1].label = "Example Mac".into(); + assert_eq!( + environment_trigger_label(&search(None, Some("a")), &dup), + "Example Mac \u{b7} a.example:3773" + ); + } + + #[test] + fn picker_search_matches_label_secondary_and_value() { + let d = data(); + let rows = project_choices(&search(None, None), &d); + assert_eq!(filter(&rows, " EXAMPLE two ").len(), 1); + assert_eq!(filter(&rows, "projects/one").len(), 1); + assert_eq!(filter(&rows, "two").len(), 1); + assert!(filter(&rows, "nothing").is_empty()); + assert_eq!(filter(&rows, "").len(), 2); + assert!(!searchable(6) && searchable(7)); + } + + #[test] + fn avatars_follow_the_web_algorithm() { + assert_eq!(crate::projects::initials("my-app"), "MA"); + assert_eq!(crate::projects::initials("Example One"), "EO"); + assert_eq!(crate::projects::initials("app2go"), "A2"); + assert_eq!(crate::projects::initials("!!!"), "PR"); + assert_eq!( + crate::projects::color_index(""), + crate::projects::color_index("project") + ); + assert_eq!( + crate::projects::color_index(" Example "), + crate::projects::color_index("example") + ); + assert_ne!(avatar_color(5, false), avatar_color(5, true)); + assert_eq!(avatar_color(18, false), avatar_color(0, false)); + } + + #[test] + fn search_finds_every_labs_row_and_nothing_from_other_tabs() { + assert_eq!(search_hits("zen").first(), Some(&"tool-call-visibility")); + assert_eq!(search_hits("simlock").first(), Some(&"simlock-hosts")); + assert_eq!(search_hits("guest").first(), Some(&"guest-links")); + assert_eq!(search_hits("plan").first(), Some(&"legacy-plan-mode")); + assert_eq!( + search_hits("meter").first(), + Some(&"legacy-context-window-indicator") + ); + assert!(LABS_ITEMS.iter().all(|i| i.section == Section::Labs)); + assert_eq!(LABS_ITEMS.len(), 8); + } + + #[test] + fn links_point_where_the_web_does() { + let links = |pieces: &[Piece]| -> Vec { + pieces + .iter() + .filter_map(|p| match p { + Piece::Link(_, link) => Some(*link), + Piece::Text(_) => None, + }) + .collect() + }; + assert_eq!(links(GUEST_LINKS), [Link::Section(Section::Access)]); + assert_eq!( + links(LINKED_ENVIRONMENTS), + [Link::Section(Section::Connections)] + ); + assert_eq!( + links(SIMLOCK_HOSTS), + [ + Link::External("https://incubator.callstack.com/simlock/"), + Link::Section(Section::Devices) + ] + ); + assert!( + GUEST_LINKS + .iter() + .all(|p| !matches!(p, Piece::Text(t) if t.contains('\u{2014}'))) + ); + } + fn entry(key: &str, scratch: bool, logical: Option<&str>) -> crate::projects::ProjectEntry { + crate::projects::ProjectEntry { + key: key.to_owned(), + id: key.to_owned(), + member_ids: vec![key.to_owned()], + title: key.to_owned(), + initials: "EX".to_owned(), + color: 0, + pinned: false, + scratch, + root: format!("/home/user/projects/{key}"), + logical_key: logical.map(str::to_owned), + remote: Vec::new(), + } + } + + #[test] + fn scope_data_merges_projects_across_environments() { + let other = crate::projects::EnvironmentProjects { + id: "env-2".to_owned(), + label: "Example Mac".to_owned(), + machine: MachineKind::Laptop, + projects: vec![ + entry("b", false, Some("repo")), + entry("only-b", false, None), + ], + }; + let data = scope_data( + "Example Server", + MachineKind::Server, + &[ + entry("a", false, Some("repo")), + entry("scratch", true, None), + ], + &[other], + ); + assert_eq!(data.environments.len(), 2); + assert!(data.environments[0].writable && !data.environments[1].writable); + let names: Vec<_> = data.projects.iter().map(|p| p.key.as_str()).collect(); + assert_eq!(names, ["repo", "only-b"]); + assert_eq!(data.projects[0].environments, ["primary", "env-2"]); + } +} diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index c4129dee7115..8b5c25c9471e 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -69,6 +69,9 @@ const SIDEBAR_DEFAULT_WIDTH: f32 = 256.; const SIDEBAR_MIN_WIDTH: f32 = 208.; /// The web's `THREAD_MAIN_CONTENT_MIN_WIDTH`, `40 * 16`. const MAIN_CONTENT_MIN_WIDTH: f32 = 640.; +/// The web's `--chat-content-max-width` (`index.css:131`), `46rem`: the heading, composer and +/// transcript column. +const CHAT_CONTENT_WIDTH: f32 = 736.; /// The rail's hit area, the web's `w-4`, centered on the sidebar's right edge. const SIDEBAR_RAIL_WIDTH: f32 = 16.; /// The web's `--workspace-topbar-height` on macOS desktop. @@ -444,7 +447,7 @@ impl MainView { card, toasts: cx.new(|cx| ToastHost::new(TOPBAR_HEIGHT, window, cx)), usage: cx.new(|cx| usage_dialog::UsageDialog::new(actions.clone(), cx)), - settings: cx.new(|cx| settings::SettingsDialog::new(window, cx)), + settings: cx.new(|cx| settings::SettingsDialog::new(actions.clone(), window, cx)), slow_toast: None, main_width: Cell::new(0.), sending: None, @@ -479,6 +482,30 @@ impl MainView { }); } + /// Hands the Labs tab the scope bar's choices and the attached environment's flags. + fn sync_labs(&mut self, cx: &mut Context) { + let (label, machine, entries) = match &self.status { + Status::Connected { + label, + machine, + shell, + .. + } => ( + label.clone(), + *machine, + shell + .as_ref() + .map(|s| s.project_entries.clone()) + .unwrap_or_default(), + ), + _ => return, + }; + let data = crate::settings_labs::scope_data(&label, machine, &entries, &self.environments); + let flags = self.providers.as_ref().map(|c| c.labs_flags()); + self.settings + .update(cx, |settings, cx| settings.set_labs_data(data, flags, cx)); + } + pub fn set_status(&mut self, status: Status, cx: &mut Context) { if let Status::ProjectFailed(message) = status { self.adding_project = None; @@ -498,12 +525,14 @@ impl MainView { Some(known) => *known = other, None => self.environments.push(other), } + self.sync_labs(cx); return cx.notify(); } if let Status::Providers(catalog) = status { self.usage .update(cx, |usage, cx| usage.set_providers(&catalog, cx)); self.providers = Some(*catalog); + self.sync_labs(cx); return cx.notify(); } if let Status::Thread { id, transcript } = status { @@ -545,6 +574,11 @@ impl MainView { return cx.notify(); } if let Status::Call { token, event } = status { + if token >= crate::settings_labs::TOKEN_BASE { + self.settings + .update(cx, |settings, cx| settings.on_labs_call(token, event, cx)); + return; + } if token >= usage_dialog::TOKEN_BASE { self.usage .update(cx, |usage, cx| usage.on_call(token, event, cx)); @@ -2590,7 +2624,8 @@ impl MainView { .min_h_0() .w_full() .when(card.is_some(), |area| { - area.pr(px(details::CARD_WIDTH + details::CARD_GAP + 32.)) + let (left, right) = details::lane_insets(self.main_width.get(), true); + area.pl(px(left)).pr(px(right)) }) .child(content) .children(card), @@ -3052,7 +3087,7 @@ impl MainView { )) .child( v_flex() - .w(px(720.)) + .w(px(CHAT_CONTENT_WIDTH)) .max_w_full() .child(self.composer_box(cx)) .children(active.and_then(|entry| self.context_strip(entry, cx))), @@ -3286,7 +3321,7 @@ impl MainView { ) } - /// One row in the web's 720px column: the user's words in a bubble on the right, the + /// One row in the web's chat content column: the user's words in a bubble on the right, the /// assistant's plain, tool work as a muted line. fn transcript_item(&self, ix: usize, cx: &App) -> AnyElement { let Some(entry) = self @@ -3334,7 +3369,7 @@ impl MainView { .when(last, |row| { row.pb(px(8. + self.reserved_composer_height.get())) }) - .child(div().w_full().max_w(px(720.)).child(body)) + .child(div().w_full().max_w(px(CHAT_CONTENT_WIDTH)).child(body)) .into_any_element() } @@ -4146,7 +4181,7 @@ impl MainView { ); let chips = chip_infos(&self.attachments); v_flex() - .w(px(720.)) + .w(px(CHAT_CONTENT_WIDTH)) .max_w_full() .p_px() .rounded(px(16.)) diff --git a/apps/desktop-gpui/src/ui/settings.rs b/apps/desktop-gpui/src/ui/settings.rs index 2c3f85cb9778..7fa5a99909b6 100644 --- a/apps/desktop-gpui/src/ui/settings.rs +++ b/apps/desktop-gpui/src/ui/settings.rs @@ -2,19 +2,24 @@ //! navigation (`SettingsSidebarNav.tsx`) and the General tab (`GeneralSettingsPanel`). mod components; +mod diffs_panel; +mod labs_panel; +mod scope_pickers; mod tokens; use super::*; use crate::assets::SNAP_SHOT; use crate::settings::{ - self as model, DeviceSettings, GENERAL_ITEMS, GroupingMode, NAV_GROUPS, NotificationMode, - QuitMode, SearchItem, Section, SortOrder, TimeFormat, + self as model, DeviceSettings, FollowUp, GroupingMode, NAV_GROUPS, NotificationMode, QuitMode, + SearchItem, Section, SendShortcut, SortOrder, TimeFormat, }; use components::{ - HoverHandler, KeyHandler, Menu, PickHandler, SelectTrigger, close_button, ghost_button, group, - reset_button, row, section, select_menu, switch, + HoverHandler, KeyHandler, Menu, PickHandler, SelectTrigger, close_button, disclosure, + ghost_button, group, reset_button, row, section, select_menu, select_menu_checked, switch, }; +use diffs_panel::DiffsPanel; use gpui::{ClickEvent, FocusHandle, KeyBinding, KeyDownEvent, ScrollHandle, Subscription}; +use labs_panel::{LabsEvent, LabsPanel}; use std::rc::Rc; use tokens::Tokens; @@ -36,13 +41,18 @@ pub fn bindings() -> Vec { ] } -/// The four selects of the General tab. +/// Whether the primary modifier prints as the Command symbol (`isMacPlatform`). +const MAC: bool = cfg!(target_os = "macos"); + +/// The selects of the General and Chat tabs. #[derive(Clone, Copy, PartialEq, Eq)] enum Pick { Notifications, ProjectOrder, Quit, Time, + SendShortcut, + FollowUp, } impl Pick { @@ -52,6 +62,8 @@ impl Pick { Self::ProjectOrder => "settings-project-order", Self::Quit => "settings-quit-shortcut", Self::Time => "settings-time-format", + Self::SendShortcut => "settings-send-shortcut", + Self::FollowUp => "settings-follow-up-behavior", } } @@ -61,15 +73,26 @@ impl Pick { Self::Notifications => 224., Self::ProjectOrder => 176., Self::Quit | Self::Time => 160., + Self::SendShortcut | Self::FollowUp => 0., } } + /// `w-auto min-w-0` (SettingsPanels.tsx Chat tab): sized by the label. + fn auto_width(self) -> bool { + matches!(self, Self::SendShortcut | Self::FollowUp) + } + fn labels(self) -> Vec<&'static str> { match self { Self::Notifications => NotificationMode::ALL.iter().map(|m| m.label()).collect(), Self::ProjectOrder => SortOrder::ALL.iter().map(|m| m.label()).collect(), Self::Quit => QuitMode::ALL.iter().map(|m| m.label()).collect(), Self::Time => TimeFormat::ALL.iter().map(|m| m.label()).collect(), + Self::SendShortcut => SendShortcut::ALL + .iter() + .map(|m| m.menu_label(MAC)) + .collect(), + Self::FollowUp => FollowUp::ALL.iter().map(|m| m.label()).collect(), } } @@ -87,6 +110,10 @@ impl Pick { Self::Time => TimeFormat::ALL .iter() .position(|m| *m == settings.time_format), + Self::SendShortcut => SendShortcut::ALL + .iter() + .position(|m| *m == settings.send_shortcut), + Self::FollowUp => FollowUp::ALL.iter().position(|m| *m == settings.follow_up), }; index.unwrap_or(0) } @@ -97,14 +124,18 @@ impl Pick { Self::ProjectOrder => settings.project_order = SortOrder::ALL[index], Self::Quit => settings.confirm_quit = QuitMode::ALL[index], Self::Time => settings.time_format = TimeFormat::ALL[index], + Self::SendShortcut => settings.send_shortcut = SendShortcut::ALL[index], + Self::FollowUp => settings.follow_up = FollowUp::ALL[index], } } - const ALL: [Self; 4] = [ + const ALL: [Self; 6] = [ Self::Notifications, Self::ProjectOrder, Self::Quit, Self::Time, + Self::SendShortcut, + Self::FollowUp, ]; } @@ -120,14 +151,28 @@ pub struct SettingsDialog { /// "/" moved focus to the search; its character must not land in the field. swallow_slash: bool, menu: Option<(Pick, usize)>, - triggers: [FocusHandle; 4], + triggers: [FocusHandle; 6], + /// `AdvancedSettings`' local `open` state: not persisted, closed again on leaving the tab. + advanced_open: bool, + diffs: Entity, + labs: Entity, confirm_restore: bool, scroll: ScrollHandle, _search_events: Subscription, + _labs_events: Subscription, } impl SettingsDialog { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { + pub fn new( + actions: crate::connection::ActionSender, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let labs = cx.new(|cx| LabsPanel::new(actions, window, cx)); + let labs_events = cx.subscribe_in(&labs, window, |this, _, event: &LabsEvent, _, cx| { + let LabsEvent::OpenSection(section) = event; + this.select_section(*section, cx); + }); let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search")); let events = cx.subscribe_in( &search, @@ -150,9 +195,13 @@ impl SettingsDialog { swallow_slash: false, menu: None, triggers: std::array::from_fn(|_| cx.focus_handle().tab_stop(true)), + advanced_open: false, + diffs: cx.new(DiffsPanel::new), + labs, confirm_restore: false, scroll: ScrollHandle::new(), _search_events: events, + _labs_events: labs_events, } } @@ -163,7 +212,10 @@ impl SettingsDialog { self.open = true; self.previous_focus = window.focused(cx); self.settings = DeviceSettings::load(); + self.diffs.update(cx, |panel, cx| panel.reload(cx)); + self.labs.update(cx, |panel, cx| panel.reload(cx)); self.menu = None; + self.advanced_open = false; self.confirm_restore = false; window.focus(&self.focus, cx); cx.notify(); @@ -181,6 +233,27 @@ impl SettingsDialog { cx.notify(); } + /// The scope bar's data and the attached environment's Labs flags. + pub fn set_labs_data( + &mut self, + data: crate::settings_labs::ScopeData, + flags: Option, + cx: &mut Context, + ) { + self.labs + .update(cx, |panel, cx| panel.set_data(data, flags, cx)); + } + + pub fn on_labs_call( + &mut self, + token: u64, + event: crate::status::CallEvent, + cx: &mut Context, + ) { + self.labs + .update(cx, |panel, cx| panel.on_call(token, event, cx)); + } + fn query(&self, cx: &App) -> String { self.search.read(cx).value().to_string() } @@ -188,7 +261,7 @@ impl SettingsDialog { fn results(&self, cx: &App) -> Vec<&'static SearchItem> { let desktop = true; let query = self.query(cx); - model::search(&query, GENERAL_ITEMS) + model::search(&query, model::searchable()) .into_iter() .filter(|item| desktop || !item.desktop_only) .collect() @@ -217,6 +290,10 @@ impl SettingsDialog { fn activate_result(&mut self, window: &mut Window, cx: &mut Context) { let results = self.results(cx); if let Some(item) = results.get(self.active_result) { + if item.section != self.section { + self.advanced_open = false; + } + self.advanced_open |= item.id == "skills-in-slash-menu"; self.section = item.section; self.clear_search(window, cx); window.focus(&self.focus, cx); @@ -225,12 +302,27 @@ impl SettingsDialog { } fn select_section(&mut self, section: Section, cx: &mut Context) { + if section != self.section { + self.advanced_open = false; + } self.section = section; self.scroll.set_offset(gpui::point(px(0.), px(0.))); cx.notify(); } fn escape(&mut self, window: &mut Window, cx: &mut Context) { + if self + .labs + .update(cx, |panel, cx| panel.close_menu(window, cx)) + { + return; + } + if self + .diffs + .update(cx, |panel, cx| panel.close_menu(window, cx)) + { + return; + } if self.confirm_restore { self.confirm_restore = false; } else if self.menu.is_some() { @@ -272,6 +364,8 @@ impl SettingsDialog { fn restore_defaults(&mut self, cx: &mut Context) { self.confirm_restore = false; self.last_grouping = GroupingMode::Repository; + self.diffs + .update(cx, |panel, cx| panel.restore_defaults(cx)); self.update(|settings| *settings = DeviceSettings::default(), cx); } @@ -419,7 +513,12 @@ impl SettingsDialog { let theme = Tokens::of(cx); let (muted, border, foreground) = (theme.muted_foreground, theme.border, theme.foreground); let general = self.section == Section::General; - let disabled = self.settings.changed_labels().is_empty(); + let disabled = self + .diffs + .read(cx) + .settings() + .merge_labels(self.settings.changed_labels()) + .is_empty(); let crumbs = h_flex() .min_w_0() .items_center() @@ -496,7 +595,12 @@ impl SettingsDialog { } else { (theme.popover, theme.accent.opacity(0.5)) }; - let labels = self.settings.changed_labels().join(", "); + let labels = self + .diffs + .read(cx) + .settings() + .merge_labels(self.settings.changed_labels()) + .join(", "); let cancel = div() .id("settings-restore-cancel") .h(px(32.)) @@ -869,8 +973,17 @@ impl SettingsDialog { /// `SettingsPageContainer`: `max-w-4xl px-6 pt-6 pb-12 gap-8`. fn body(&self, content_width: f32, window: &mut Window, cx: &mut Context) -> AnyElement { + let wide = content_width >= ROW_WIDE + 48.; let panel = if self.section == Section::General { - self.general(content_width >= ROW_WIDE + 48., window, cx) + self.general(wide, window, cx) + } else if self.section == Section::Chat { + self.chat(wide, window, cx) + } else if self.section == Section::Diffs { + self.diffs.update(cx, |panel, cx| panel.set_wide(wide, cx)); + vec![self.diffs.clone().into_any_element()] + } else if self.section == Section::Labs { + self.labs.update(cx, |panel, cx| panel.set_wide(wide, cx)); + vec![self.labs.clone().into_any_element()] } else { Vec::new() }; @@ -962,6 +1075,11 @@ impl SettingsDialog { self.triggers[slot].clone(), on_key, ); + let trigger = if pick.auto_width() { + trigger.auto_width() + } else { + trigger + }; let on_pick: PickHandler = { let view = view.clone(); Rc::new(move |index, window, cx| { @@ -979,6 +1097,7 @@ impl SettingsDialog { }) }) }; + let check = pick == Pick::SendShortcut; Popover::new(("settings-select", slot)) .anchor(Anchor::TopRight) .offset(px(4.)) @@ -996,22 +1115,130 @@ impl SettingsDialog { }) .trigger(trigger) .content(move |_, _, cx| { - select_menu( - &Menu { - id: pick.id(), - labels: labels.clone(), - selected, - highlighted, - width: pick.width(), - on_pick: on_pick.clone(), - on_hover: on_hover.clone(), - }, - cx, - ) + let menu = Menu { + id: pick.id(), + labels: labels.clone(), + selected, + highlighted, + width: pick.width(), + on_pick: on_pick.clone(), + on_hover: on_hover.clone(), + }; + if check { + select_menu_checked(&menu, cx) + } else { + select_menu(&menu, cx) + } }) .into_any_element() } + /// `ChatSettingsPanel` (SettingsPanels.tsx:1075-1287): Composer and Follow-ups. + fn chat(&self, wide: bool, window: &mut Window, cx: &mut Context) -> Vec { + let s = self.settings; + let d = DeviceSettings::default(); + let view = cx.entity(); + let skills = row( + "Show skills in slash menu", + "Also include skills in the / command menu. Skills always appear when you type $.", + self.reset( + "settings-reset-skills-in-slash-menu", + s.skills_in_slash_menu != d.skills_in_slash_menu, + |s, d| s.skills_in_slash_menu = d.skills_in_slash_menu, + window, + cx, + ), + self.flag( + "settings-skills-in-slash-menu", + s.skills_in_slash_menu, + |s| s.skills_in_slash_menu = !s.skills_in_slash_menu, + window, + cx, + ), + wide, + cx, + ); + let advanced = disclosure( + "settings-advanced", + self.advanced_open, + Rc::new(move |_, cx| { + view.update(cx, |this, cx| { + this.advanced_open = !this.advanced_open; + cx.notify(); + }) + }), + vec![skills.into_any_element()], + window, + cx, + ); + let composer = group( + vec![ + row( + "Rich text composer", + "Show formatted Markdown as you type.", + self.reset( + "settings-reset-rich-text-composer", + s.composer_rich_text != d.composer_rich_text, + |s, d| s.composer_rich_text = d.composer_rich_text, + window, + cx, + ), + self.flag( + "settings-rich-text-composer", + s.composer_rich_text, + |s| s.composer_rich_text = !s.composer_rich_text, + window, + cx, + ), + wide, + cx, + ) + .into_any_element(), + row( + "Send shortcut", + "Choose when Enter sends a prompt or inserts a new line", + self.reset( + "settings-reset-send-shortcut", + s.send_shortcut != d.send_shortcut, + |s, d| s.send_shortcut = d.send_shortcut, + window, + cx, + ), + self.select(Pick::SendShortcut, cx), + wide, + cx, + ) + .into_any_element(), + advanced, + ], + cx, + ); + let follow_ups = group( + vec![ + row( + "Follow-up behavior", + model::follow_up_description(s.send_shortcut, MAC), + self.reset( + "settings-reset-follow-up-behavior", + s.follow_up != d.follow_up, + |s, d| s.follow_up = d.follow_up, + window, + cx, + ), + self.select(Pick::FollowUp, cx), + wide, + cx, + ) + .into_any_element(), + ], + cx, + ); + vec![ + section("Composer", composer).into_any_element(), + section("Follow-ups", follow_ups).into_any_element(), + ] + } + /// `GeneralSettingsPanel` (SettingsPanels.tsx:781-1067): Notifications, Sidebar, /// Confirmations, Display. fn general(&self, wide: bool, window: &mut Window, cx: &mut Context) -> Vec { diff --git a/apps/desktop-gpui/src/ui/settings/components.rs b/apps/desktop-gpui/src/ui/settings/components.rs index 3bd84290a355..313194abb3ca 100644 --- a/apps/desktop-gpui/src/ui/settings/components.rs +++ b/apps/desktop-gpui/src/ui/settings/components.rs @@ -139,7 +139,55 @@ pub(super) fn row( div().px_4().py_3().child(inner) } -/// `SettingResetButton` (settingsLayout.tsx): `icon-micro` `ghost-muted`, `size-3` Undo2. +/// Hover state of a `TooltipPopup side="top"`, shared by the control that opens it. +#[derive(Default)] +struct TipState { + shown: bool, + epoch: u64, +} + +/// Base UI's default `Tooltip.Root` open delay. +const TIP_DELAY: std::time::Duration = std::time::Duration::from_millis(600); + +/// `TooltipPopup` (tooltip.tsx:120-157): above the trigger, `sideOffset` 4, centered, a +/// `rounded-md border bg-popover shadow-md/5 text-xs` box with `px-2 py-1` and `leading-snug`. +fn tip_popup(text: &'static str, cx: &App) -> gpui::Div { + let theme = Tokens::of(cx); + div() + .absolute() + .bottom_full() + .mb(px(4.)) + .left_0() + .right_0() + .flex() + .justify_center() + .child( + div() + .flex_shrink_0() + .whitespace_nowrap() + .px_2() + .py_1() + .rounded(px(6.)) + .border_1() + .border_color(theme.border) + .bg(theme.popover) + .text_xs() + .line_height(px(15.)) + .text_color(theme.foreground) + .shadow(vec![BoxShadow { + color: gpui::black().opacity(0.05), + offset: point(px(0.), px(4.)), + blur_radius: px(6.), + spread_radius: px(-1.), + inset: false, + }]) + .child(text), + ) +} + +/// `SettingResetButton` (settingsLayout.tsx:548-580): `Button size="icon-micro" variant="ghost-muted"` +/// (`size-5 rounded-sm`, muted, `bg-accent` and foreground on hover) holding a `size-3` Undo2, +/// with a "Reset to default" tooltip above it that opens after 600ms of hover or on keyboard focus. pub(super) fn reset_button( id: &'static str, on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, @@ -149,9 +197,15 @@ pub(super) fn reset_button( let key = ElementId::from(id); let handle = tab_stop(&key, window, cx); let visible = focus_visible(&handle, window); + let tip = window.use_keyed_state(ElementId::from((key.clone(), "tip")), cx, |_, _| { + TipState::default() + }); + let shown = tip.read(cx).shown || visible; let theme = Tokens::of(cx); let (muted, hover, foreground) = (theme.muted_foreground, theme.accent, theme.foreground); let ring = focus_ring(4., cx); + let popup = shown.then(|| gpui::deferred(tip_popup("Reset to default", cx))); + let (hovered_tip, clicked_tip) = (tip.clone(), tip.clone()); div() .id(key) .track_focus(&handle) @@ -164,9 +218,37 @@ pub(super) fn reset_button( .text_color(muted) .cursor_pointer() .hover(move |style| style.bg(hover).text_color(foreground)) - .tooltip(tooltip("Reset to default")) - .on_click(on_click) + .on_hover(move |entered, window, cx| { + let epoch = hovered_tip.update(cx, |state, _| { + state.epoch += 1; + state.shown = false; + state.epoch + }); + window.refresh(); + if !*entered { + return; + } + let tip = hovered_tip.clone(); + window + .spawn(cx, async move |cx| { + cx.background_executor().timer(TIP_DELAY).await; + cx.update(|window, cx| { + tip.update(cx, |state, _| state.shown = state.epoch == epoch); + window.refresh(); + }) + .ok(); + }) + .detach(); + }) + .on_click(move |event, window, cx| { + clicked_tip.update(cx, |state, _| { + state.epoch += 1; + state.shown = false; + }); + on_click(event, window, cx); + }) .child(Icon::new(Icons::Undo2).size(px(12.))) + .children(popup) .when(visible, |button| button.child(ring)) .into_any_element() } @@ -238,9 +320,23 @@ pub(super) struct SelectTrigger { handle: FocusHandle, open: bool, on_key: KeyHandler, + leading: Option, + auto_width: bool, } impl SelectTrigger { + /// `w-auto`: the trigger is as wide as its label instead of `width`. + pub(super) fn auto_width(mut self) -> Self { + self.auto_width = true; + self + } + + /// Content before the label, as the diff colors select puts its two dots. + pub(super) fn leading(mut self, leading: AnyElement) -> Self { + self.leading = Some(leading); + self + } + pub(super) fn new( id: &'static str, label: &'static str, @@ -256,6 +352,8 @@ impl SelectTrigger { handle, open: false, on_key, + leading: None, + auto_width: false, } } } @@ -314,7 +412,7 @@ impl RenderOnce for SelectTrigger { h_flex() .id(self.id) .track_focus(&self.handle) - .w(px(self.width)) + .when(!self.auto_width, |trigger| trigger.w(px(self.width))) .max_w_full() .min_h(px(28.)) .items_center() @@ -333,7 +431,14 @@ impl RenderOnce for SelectTrigger { .cursor_pointer() .shadow(shadow) .on_key_down(move |event, window, cx| on_key(event, window, cx)) - .child(div().min_w_0().flex_1().truncate().child(self.label)) + .children(self.leading) + .child( + div() + .min_w_0() + .when(!self.auto_width, |label| label.flex_1()) + .truncate() + .child(self.label), + ) .child( div() .mr(px(-4.)) @@ -360,6 +465,16 @@ pub(super) struct Menu { } pub(super) fn select_menu(menu: &Menu, cx: &App) -> gpui::Div { + menu_items(menu, false, cx) +} + +/// `select_menu` with a trailing check on the chosen item (the send shortcut items, +/// SettingsPanels.tsx Chat tab). +pub(super) fn select_menu_checked(menu: &Menu, cx: &App) -> gpui::Div { + menu_items(menu, true, cx) +} + +fn menu_items(menu: &Menu, check: bool, cx: &App) -> gpui::Div { let Menu { id, labels, @@ -418,7 +533,11 @@ pub(super) fn select_menu(menu: &Menu, cx: &App) -> gpui::Div { } }) .on_click(move |_, window, cx| pick(index, window, cx)) + .when(check, |item| item.justify_between().gap_4()) .child(*label) + .when(check && index == selected, |item| { + item.child(Icon::new(IconName::Check).size(px(16.))) + }) })) } @@ -455,3 +574,73 @@ pub(super) fn close_button(id: &'static str, cx: &App) -> gpui::Stateful, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let key = ElementId::from(id); + let handle = tab_stop(&key, window, cx); + let visible = focus_visible(&handle, window); + let theme = Tokens::of(cx); + let (muted, foreground, ring) = (theme.muted_foreground, theme.foreground, theme.ring); + let divider = theme.border.opacity(0.5); + let click = on_toggle.clone(); + let chevron = Icon::new(IconName::ChevronRight).size(px(14.)); + let chevron = if open { + chevron.rotate(gpui::radians(std::f32::consts::FRAC_PI_2)) + } else { + chevron + }; + let trigger = h_flex() + .id(key) + .track_focus(&handle) + .relative() + .min_h(px(40.)) + .w_full() + .items_center() + .gap(px(6.)) + .px_4() + .when(!open, |trigger| trigger.rounded_b(px(12.))) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(muted) + .cursor_pointer() + .hover(move |style| style.text_color(foreground)) + .on_click(move |_, window, cx| click(window, cx)) + .on_key_down(move |event: &KeyDownEvent, window, cx| { + if matches!(event.keystroke.key.as_str(), "space" | "enter") { + cx.stop_propagation(); + on_toggle(window, cx); + } + }) + .child(div().flex_shrink_0().child(chevron)) + .child("Advanced") + .when(visible, |trigger| { + trigger.child( + div() + .absolute() + .inset_0() + .when(!open, |ring_box| ring_box.rounded_b(px(12.))) + .border_2() + .border_color(ring), + ) + }); + let mut panel = v_flex().border_t_1().border_color(divider); + for (index, row) in rows.into_iter().enumerate() { + if index > 0 { + panel = panel.child(div().h(px(1.)).w_full().bg(divider)); + } + panel = panel.child(row); + } + v_flex() + .child(trigger) + .when(open, |disclosure| disclosure.child(panel)) + .into_any_element() +} diff --git a/apps/desktop-gpui/src/ui/settings/diffs_panel.rs b/apps/desktop-gpui/src/ui/settings/diffs_panel.rs new file mode 100644 index 000000000000..7349ffe4a42e --- /dev/null +++ b/apps/desktop-gpui/src/ui/settings/diffs_panel.rs @@ -0,0 +1,431 @@ +//! The "Diffs & pull requests" tab: `DiffsSettingsPanel` (SettingsPanels.tsx:1289-1430) with +//! `DiffColorsSettingsRow` (1236-1287) and `PullRequestLinksOpenInAppSetting` +//! (IntegrationsSettings.tsx:775-802). It owns its select menu and focus handles, so the +//! General tab's state stays untouched. + +use super::components::{ + HoverHandler, KeyHandler, Menu, PickHandler, SelectTrigger, group, reset_button, row, section, + select_menu, switch, +}; +use super::tokens::Tokens; +use super::*; +use crate::settings_diffs::{DiffColors, DiffLayout, DiffSettings, FileState}; +use gpui::{FocusHandle, KeyDownEvent, rgb}; +use std::rc::Rc; + +/// The three selects of the tab. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Choice { + Layout, + Files, + Colors, +} + +impl Choice { + const ALL: [Self; 3] = [Self::Layout, Self::Files, Self::Colors]; + + fn id(self) -> &'static str { + match self { + Self::Layout => "settings-diff-layout", + Self::Files => "settings-diff-file-state", + Self::Colors => "settings-diff-colors", + } + } + + /// `w-full sm:w-40` on all three. + fn width(self) -> f32 { + 160. + } + + /// The menu's labels; the colors menu marks its default (SettingsPanels.tsx:1281). + fn labels(self) -> Vec<&'static str> { + match self { + Self::Layout => DiffLayout::ALL.iter().map(|v| v.label()).collect(), + Self::Files => FileState::ALL.iter().map(|v| v.label()).collect(), + Self::Colors => DiffColors::ALL.iter().map(|v| v.menu_label()).collect(), + } + } + + /// What the trigger shows for the current value. + fn value_label(self, settings: &DiffSettings) -> &'static str { + match self { + Self::Layout => settings.layout.label(), + Self::Files => FileState::of(settings.files_collapsed).label(), + Self::Colors => settings.colors.label(), + } + } + + fn selected(self, settings: &DiffSettings) -> usize { + let index = match self { + Self::Layout => DiffLayout::ALL.iter().position(|v| *v == settings.layout), + Self::Files => FileState::ALL + .iter() + .position(|v| *v == FileState::of(settings.files_collapsed)), + Self::Colors => DiffColors::ALL.iter().position(|v| *v == settings.colors), + }; + index.unwrap_or(0) + } + + fn apply(self, settings: &mut DiffSettings, index: usize) { + match self { + Self::Layout => settings.layout = DiffLayout::ALL[index], + Self::Files => settings.files_collapsed = FileState::ALL[index].collapsed(), + Self::Colors => settings.colors = DiffColors::ALL[index], + } + } +} + +pub struct DiffsPanel { + settings: DiffSettings, + menu: Option<(Choice, usize)>, + triggers: [FocusHandle; 3], + wide: bool, +} + +impl DiffsPanel { + pub fn new(cx: &mut Context) -> Self { + Self { + settings: DiffSettings::load(), + menu: None, + triggers: std::array::from_fn(|_| cx.focus_handle().tab_stop(true)), + wide: true, + } + } + + pub fn settings(&self) -> DiffSettings { + self.settings + } + + /// Re-reads the file and closes any menu, as the dialog does when it opens. + pub fn reload(&mut self, cx: &mut Context) { + self.settings = DiffSettings::load(); + self.menu = None; + cx.notify(); + } + + pub fn set_wide(&mut self, wide: bool, cx: &mut Context) { + if self.wide != wide { + self.wide = wide; + cx.notify(); + } + } + + pub fn restore_defaults(&mut self, cx: &mut Context) { + self.update(|settings| *settings = DiffSettings::default(), cx); + } + + /// Closes an open menu; returns whether there was one, so Escape stops there. + pub fn close_menu(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let Some((choice, _)) = self.menu.take() else { + return false; + }; + let slot = Choice::ALL.iter().position(|c| *c == choice).unwrap_or(0); + window.focus(&self.triggers[slot], cx); + cx.notify(); + true + } + + fn open_menu(&mut self, choice: Choice, cx: &mut Context) { + self.menu = Some((choice, choice.selected(&self.settings))); + cx.notify(); + } + + fn pick(&mut self, choice: Choice, index: usize, window: &mut Window, cx: &mut Context) { + self.update(|settings| choice.apply(settings, index), cx); + self.close_menu(window, cx); + } + + fn update(&mut self, change: impl FnOnce(&mut DiffSettings), cx: &mut Context) { + change(&mut self.settings); + self.settings.save(); + cx.notify(); + } + + fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { + let Some((choice, highlighted)) = self.menu else { + return; + }; + let count = choice.labels().len(); + match event.keystroke.key.as_str() { + "down" => self.menu = Some((choice, (highlighted + 1) % count)), + "up" => self.menu = Some((choice, (highlighted + count - 1) % count)), + "home" => self.menu = Some((choice, 0)), + "end" => self.menu = Some((choice, count - 1)), + "enter" | "space" => self.pick(choice, highlighted, window, cx), + _ => return, + } + cx.stop_propagation(); + cx.notify(); + } + + fn reset( + &self, + id: &'static str, + changed: bool, + restore: fn(&mut DiffSettings, &DiffSettings), + window: &mut Window, + cx: &mut Context, + ) -> Option { + let view = cx.entity(); + changed.then(|| { + reset_button( + id, + move |_, _, cx| { + view.update(cx, |this, cx| { + this.update(|s| restore(s, &DiffSettings::default()), cx) + }) + }, + window, + cx, + ) + }) + } + + fn flag( + &self, + id: &'static str, + checked: bool, + toggle: fn(&mut DiffSettings), + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let view = cx.entity(); + switch( + id, + checked, + Rc::new(move |_, cx| view.update(cx, |this, cx| this.update(toggle, cx))), + window, + cx, + ) + } + + /// Two 8px dots with a 4px gap (`size-2 rounded-full`, `gap-1`). + fn dots(&self, dark: bool) -> AnyElement { + h_flex() + .flex_shrink_0() + .gap_1() + .children( + self.settings + .colors + .dots(dark) + .map(|hex| div().size(px(8.)).rounded_full().bg(rgb(hex))), + ) + .into_any_element() + } + + /// `Select` (select.tsx) over a `Popover`, as the General tab builds it. + fn select(&self, choice: Choice, cx: &mut Context) -> AnyElement { + let view = cx.entity(); + let labels = choice.labels(); + let selected = choice.selected(&self.settings); + let open = matches!(self.menu, Some((open, _)) if open == choice); + let highlighted = match self.menu { + Some((open, index)) if open == choice => index, + _ => usize::MAX, + }; + let slot = Choice::ALL.iter().position(|c| *c == choice).unwrap_or(0); + let on_key: KeyHandler = { + let view = view.clone(); + Rc::new(move |event, _, cx| { + if matches!( + event.keystroke.key.as_str(), + "enter" | "space" | "down" | "up" + ) { + cx.stop_propagation(); + view.update(cx, |this, cx| this.open_menu(choice, cx)); + } + }) + }; + let mut trigger = SelectTrigger::new( + choice.id(), + choice.value_label(&self.settings), + choice.width(), + self.triggers[slot].clone(), + on_key, + ); + if choice == Choice::Colors { + trigger = trigger.leading(self.dots(Tokens::of(cx).dark)); + } + let on_pick: PickHandler = { + let view = view.clone(); + Rc::new(move |index, window, cx| { + view.update(cx, |this, cx| this.pick(choice, index, window, cx)) + }) + }; + let on_hover: HoverHandler = { + let view = view.clone(); + Rc::new(move |index, cx| { + view.update(cx, |this, cx| { + if this.menu.is_some_and(|(_, current)| current != index) { + this.menu = Some((choice, index)); + cx.notify(); + } + }) + }) + }; + Popover::new(("settings-diff-select", slot)) + .anchor(Anchor::TopRight) + .offset(px(4.)) + .open(open) + .p_0() + .appearance(false) + .on_open_change(move |opened, window, cx| { + view.update(cx, |this, cx| { + if *opened { + this.open_menu(choice, cx); + } else if this.menu.is_some_and(|(current, _)| current == choice) { + this.close_menu(window, cx); + } + }) + }) + .trigger(trigger) + .content(move |_, _, cx| { + select_menu( + &Menu { + id: choice.id(), + labels: labels.clone(), + selected, + highlighted, + width: choice.width(), + on_pick: on_pick.clone(), + on_hover: on_hover.clone(), + }, + cx, + ) + }) + .into_any_element() + } +} + +impl Render for DiffsPanel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let s = self.settings; + let d = DiffSettings::default(); + let wide = self.wide; + let rows = |items: Vec| -> Vec { + items + .into_iter() + .map(IntoElement::into_any_element) + .collect() + }; + + let diffs = group( + rows(vec![ + row( + "Diff layout", + "Show diffs stacked or side by side. The toggle in the diff toolbar changes this too.", + self.reset( + "settings-reset-diff-layout", + s.layout != d.layout, + |s, d| s.layout = d.layout, + window, + cx, + ), + self.select(Choice::Layout, cx), + wide, + cx, + ), + row( + "Hide whitespace changes", + "Set whether the diff panel ignores whitespace-only edits by default.", + self.reset( + "settings-reset-diff-whitespace", + s.ignore_whitespace != d.ignore_whitespace, + |s, d| s.ignore_whitespace = d.ignore_whitespace, + window, + cx, + ), + self.flag( + "settings-diff-whitespace", + s.ignore_whitespace, + |s| s.ignore_whitespace = !s.ignore_whitespace, + window, + cx, + ), + wide, + cx, + ), + row( + "Default diff file state", + "Start with files expanded or collapsed when opening diffs or a pull request's Code tab.", + self.reset( + "settings-reset-diff-file-state", + s.files_collapsed != d.files_collapsed, + |s, d| s.files_collapsed = d.files_collapsed, + window, + cx, + ), + self.select(Choice::Files, cx), + wide, + cx, + ), + row( + "Diff colors", + "Choose colors for additions and deletions, including change counts.", + self.reset( + "settings-reset-diff-colors", + s.colors != d.colors, + |s, d| s.colors = d.colors, + window, + cx, + ), + self.select(Choice::Colors, cx), + wide, + cx, + ), + ]), + cx, + ); + let pull_requests = group( + rows(vec![ + row( + "Open pull requests and changes panels proactively", + "Open linked pull requests first. Otherwise, open Changes for edits to at least 3 files or 50 lines.", + self.reset( + "settings-reset-proactive-panels", + s.proactive_panels != d.proactive_panels, + |s, d| s.proactive_panels = d.proactive_panels, + window, + cx, + ), + self.flag( + "settings-proactive-panels", + s.proactive_panels, + |s| s.proactive_panels = !s.proactive_panels, + window, + cx, + ), + wide, + cx, + ), + row( + "Open pull requests in T3 Code", + "Clicking a pull request link opens it in T3 Code. Turn off to open pull request links like any other link, on your git host.", + self.reset( + "settings-reset-pr-links-in-app", + s.pr_links_in_app != d.pr_links_in_app, + |s, d| s.pr_links_in_app = d.pr_links_in_app, + window, + cx, + ), + self.flag( + "settings-pr-links-in-app", + s.pr_links_in_app, + |s| s.pr_links_in_app = !s.pr_links_in_app, + window, + cx, + ), + wide, + cx, + ), + ]), + cx, + ); + v_flex() + .w_full() + .gap_8() + .on_key_down(cx.listener(|this, event, window, cx| this.key_down(event, window, cx))) + .child(section("Diffs", diffs)) + .child(section("Pull requests", pull_requests)) + } +} diff --git a/apps/desktop-gpui/src/ui/settings/labs_panel.rs b/apps/desktop-gpui/src/ui/settings/labs_panel.rs new file mode 100644 index 000000000000..84b30c7519c1 --- /dev/null +++ b/apps/desktop-gpui/src/ui/settings/labs_panel.rs @@ -0,0 +1,673 @@ +//! The Labs tab: `LabsSettingsPanel` (LabsSettingsPanel.tsx) and `LegacyFeaturesSection` +//! (SettingsPanels.tsx:749-783) under the scope sentence (SettingsScopeSentence.tsx:45-91). +//! The panel owns its select menu, pickers and focus handles, so the other tabs stay untouched. + +use super::components::{ + HoverHandler, KeyHandler, Menu, PickHandler, SelectTrigger, group, row, section, select_menu, + switch, +}; +use super::scope_pickers::{Picker, picker}; +use super::tokens::Tokens; +use super::*; +use crate::connection::{Action, ActionSender, Call}; +use crate::settings_labs::{ + self as labs, ALL, Choice, ChoiceIcon, GUEST_LINKS, LINKED_ENVIRONMENTS, LabsSettings, Link, + Piece, Reach, SIMLOCK_HOSTS, ScopeData, ScopeSearch, ServerFlags, ServerKey, ToolCalls, + ToolCallsDefault, +}; +use gpui::{ + EventEmitter, FocusHandle, HighlightStyle, InteractiveText, KeyDownEvent, StyledText, + UnderlineStyle, +}; +use std::collections::HashMap; +use std::rc::Rc; + +/// What the panel asks of the dialog. +pub enum LabsEvent { + /// An inline link to another section. + OpenSection(Section), +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Axis { + Project, + Environment, +} + +pub struct LabsPanel { + actions: ActionSender, + settings: LabsSettings, + flags: ServerFlags, + data: ScopeData, + scope: ScopeSearch, + wide: bool, + open: Option, + project_search: Entity, + environment_search: Entity, + tool_menu: Option, + tool_trigger: FocusHandle, + /// Server writes in flight: the key and the value to fall back to. + pending: HashMap, + next_token: u64, + _subscriptions: Vec, +} + +impl EventEmitter for LabsPanel {} + +impl LabsPanel { + pub fn new(actions: ActionSender, window: &mut Window, cx: &mut Context) -> Self { + let project_search = + cx.new(|cx| InputState::new(window, cx).placeholder("Search projects")); + let environment_search = + cx.new(|cx| InputState::new(window, cx).placeholder("Search environments")); + let subscriptions = [&project_search, &environment_search] + .into_iter() + .map(|input| { + cx.subscribe(input, |_, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }) + }) + .collect(); + Self { + actions, + settings: LabsSettings::load(), + flags: ServerFlags::default(), + data: ScopeData::default(), + scope: ScopeSearch::default(), + wide: true, + open: None, + project_search, + environment_search, + tool_menu: None, + tool_trigger: cx.focus_handle().tab_stop(true), + pending: HashMap::new(), + next_token: labs::TOKEN_BASE, + _subscriptions: subscriptions, + } + } + + /// Re-reads the file and closes any menu, as the dialog does when it opens. + pub fn reload(&mut self, cx: &mut Context) { + self.settings = LabsSettings::load(); + self.open = None; + self.tool_menu = None; + cx.notify(); + } + + pub fn set_wide(&mut self, wide: bool, cx: &mut Context) { + if self.wide != wide { + self.wide = wide; + cx.notify(); + } + } + + /// The attached environment's flags and the scope bar's choices, as the status carries them. + pub fn set_data( + &mut self, + data: ScopeData, + flags: Option, + cx: &mut Context, + ) { + let mut changed = self.data != data; + self.data = data; + if let Some(flags) = flags + && self.pending.is_empty() + && self.flags != flags + { + self.flags = flags; + changed = true; + } + if changed { + cx.notify(); + } + } + + /// Closes an open menu; returns whether there was one, so Escape stops there. + pub fn close_menu(&mut self, window: &mut Window, cx: &mut Context) -> bool { + if self.open.take().is_some() { + cx.notify(); + return true; + } + if self.tool_menu.take().is_some() { + window.focus(&self.tool_trigger, cx); + cx.notify(); + return true; + } + false + } + + /// The outcome of a server write: a failure puts the switch back. + pub fn on_call(&mut self, token: u64, event: crate::status::CallEvent, cx: &mut Context) { + use crate::status::CallEvent; + if matches!(event, CallEvent::Progress(_)) { + return; + } + let Some((key, previous)) = self.pending.remove(&token) else { + return; + }; + if matches!(event, CallEvent::Failed(_)) { + self.flags.set(key, previous); + cx.notify(); + } + } + + fn update(&mut self, change: impl FnOnce(&mut LabsSettings), cx: &mut Context) { + change(&mut self.settings); + self.settings.save(); + cx.notify(); + } + + fn set_open(&mut self, axis: Axis, opened: bool, window: &mut Window, cx: &mut Context) { + let input = self.search_of(axis).clone(); + if opened { + self.open = Some(axis); + input.update(cx, |input, cx| input.set_value("", window, cx)); + let handle = input.focus_handle(cx); + window.focus(&handle, cx); + } else if self.open == Some(axis) { + self.open = None; + } + cx.notify(); + } + + fn search_of(&self, axis: Axis) -> &Entity { + match axis { + Axis::Project => &self.project_search, + Axis::Environment => &self.environment_search, + } + } + + fn pick_scope(&mut self, axis: Axis, value: &str, cx: &mut Context) { + self.scope = match axis { + Axis::Project => self.scope.select_project(value), + Axis::Environment => self.scope.select_environment(value), + }; + self.open = None; + cx.notify(); + } + + fn link(&mut self, link: Link, cx: &mut Context) { + match link { + Link::Section(section) => cx.emit(LabsEvent::OpenSection(section)), + Link::External(url) => cx.open_url(url), + } + } + + /// A server switch: the write goes to the environments in scope that this app can write. + fn toggle_server(&mut self, key: ServerKey, cx: &mut Context) { + let Reach::Targets(_) = labs::reach(key, &self.scope, &self.data) else { + return; + }; + let previous = self.flags.get(key); + let token = self.next_token; + self.next_token += 1; + let action = Action::Call { + token, + call: Call::UpdateSettings, + payload: key.patch(!previous), + }; + if self.actions.try_send(action).is_err() { + return; + } + self.flags.set(key, !previous); + self.pending.insert(token, (key, previous)); + cx.notify(); + } + + fn open_tool_menu(&mut self, cx: &mut Context) { + self.tool_menu = Some(self.tool_index()); + cx.notify(); + } + + fn tool_index(&self) -> usize { + ToolCalls::ALL + .iter() + .position(|v| *v == self.settings.tool_calls.0) + .unwrap_or(0) + } + + fn pick_tool(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + self.update( + |s| s.tool_calls = ToolCallsDefault(ToolCalls::ALL[index]), + cx, + ); + self.close_menu(window, cx); + } + + fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { + let Some(highlighted) = self.tool_menu else { + return; + }; + let count = ToolCalls::ALL.len(); + match event.keystroke.key.as_str() { + "down" => self.tool_menu = Some((highlighted + 1) % count), + "up" => self.tool_menu = Some((highlighted + count - 1) % count), + "home" => self.tool_menu = Some(0), + "end" => self.tool_menu = Some(count - 1), + "enter" | "space" => self.pick_tool(highlighted, window, cx), + _ => return, + } + cx.stop_propagation(); + cx.notify(); + } + + fn flag( + &self, + id: &'static str, + checked: bool, + toggle: fn(&mut LabsSettings), + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let view = cx.entity(); + switch( + id, + checked, + Rc::new(move |_, cx| view.update(cx, |this, cx| this.update(toggle, cx))), + window, + cx, + ) + } + + /// `ScopedSwitch` on an environment-wide key: inert, at half opacity under a tooltip, when + /// the scope cannot write it (settingsLayout.tsx:405-448). + fn server_switch( + &self, + id: &'static str, + key: ServerKey, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let view = cx.entity(); + let checked = self.flags.get(key); + match labs::reach(key, &self.scope, &self.data) { + Reach::Targets(_) => switch( + id, + checked, + Rc::new(move |_, cx| view.update(cx, |this, cx| this.toggle_server(key, cx))), + window, + cx, + ), + Reach::Inert(message) => div() + .id((id, 1usize)) + .opacity(0.5) + .tooltip(tooltip(message)) + .child(switch(id, checked, Rc::new(|_, _| {}), window, cx)) + .into_any_element(), + } + } + + /// A description with inline links: `underline underline-offset-2`. + fn linked( + &self, + id: &'static str, + pieces: &'static [Piece], + cx: &mut Context, + ) -> AnyElement { + let muted = Tokens::of(cx).muted_foreground; + let mut text = String::new(); + let mut ranges = Vec::new(); + let mut targets = Vec::new(); + for piece in pieces { + match piece { + Piece::Text(plain) => text.push_str(plain), + Piece::Link(label, link) => { + let start = text.len(); + text.push_str(label); + ranges.push(start..text.len()); + targets.push(*link); + } + } + } + let underline = HighlightStyle { + underline: Some(UnderlineStyle { + thickness: px(1.), + color: Some(muted), + wavy: false, + }), + ..HighlightStyle::default() + }; + let styled = StyledText::new(text).with_highlights( + ranges + .iter() + .cloned() + .map(|range| (range, underline)) + .collect::>(), + ); + let view = cx.entity(); + InteractiveText::new(id, styled) + .on_click(ranges, move |index, _, cx| { + let link = targets[index]; + view.update(cx, |this, cx| this.link(link, cx)); + }) + .into_any_element() + } + + /// `Select` (select.tsx) over a `Popover`, as the General tab builds it. + fn tool_select(&self, cx: &mut Context) -> AnyElement { + let view = cx.entity(); + let labels: Vec<&'static str> = ToolCalls::ALL.iter().map(|v| v.label()).collect(); + let selected = self.tool_index(); + let open = self.tool_menu.is_some(); + let highlighted = self.tool_menu.unwrap_or(usize::MAX); + let on_key: KeyHandler = { + let view = view.clone(); + Rc::new(move |event, _, cx| { + if matches!( + event.keystroke.key.as_str(), + "enter" | "space" | "down" | "up" + ) { + cx.stop_propagation(); + view.update(cx, |this, cx| this.open_tool_menu(cx)); + } + }) + }; + let trigger = SelectTrigger::new( + "settings-tool-call-visibility", + ToolCalls::ALL[selected].label(), + 160., + self.tool_trigger.clone(), + on_key, + ); + let on_pick: PickHandler = { + let view = view.clone(); + Rc::new(move |index, window, cx| { + view.update(cx, |this, cx| this.pick_tool(index, window, cx)) + }) + }; + let on_hover: HoverHandler = { + let view = view.clone(); + Rc::new(move |index, cx| { + view.update(cx, |this, cx| { + if this.tool_menu.is_some_and(|current| current != index) { + this.tool_menu = Some(index); + cx.notify(); + } + }) + }) + }; + Popover::new("settings-tool-call-select") + .anchor(Anchor::TopRight) + .offset(px(4.)) + .open(open) + .p_0() + .appearance(false) + .on_open_change(move |opened, window, cx| { + view.update(cx, |this, cx| { + if *opened { + this.open_tool_menu(cx); + } else if this.tool_menu.is_some() { + this.close_menu(window, cx); + } + }) + }) + .trigger(trigger) + .content(move |_, _, cx| { + select_menu( + &Menu { + id: "settings-tool-call-visibility", + labels: labels.clone(), + selected, + highlighted, + width: 160., + on_pick: on_pick.clone(), + on_hover: on_hover.clone(), + }, + cx, + ) + }) + .into_any_element() + } + + fn project_picker(&self, window: &mut Window, cx: &mut Context) -> AnyElement { + let view = cx.entity(); + let selected = self + .scope + .project + .as_ref() + .and_then(|key| self.data.projects.iter().find(|p| &p.key == key)); + let spec = Picker { + id: "settings-scope-project", + label: labs::project_label(&self.scope, &self.data), + icon: selected.map(|p| ChoiceIcon::Project { + initials: p.initials.clone(), + color: p.color, + }), + all: Some(Choice { + value: ALL.to_owned(), + label: "All projects".to_owned(), + secondary: String::new(), + offline: false, + icon: ChoiceIcon::None, + }), + choices: labs::project_choices(&self.scope, &self.data), + selected: self.scope.project.clone().unwrap_or_else(|| ALL.to_owned()), + open: self.open == Some(Axis::Project), + search: self.project_search.clone(), + query: self.project_search.read(cx).value().to_string(), + on_open: { + let view = view.clone(); + Rc::new(move |opened, window, cx| { + view.update(cx, |this, cx| { + this.set_open(Axis::Project, opened, window, cx) + }) + }) + }, + on_pick: Rc::new(move |value, _, cx| { + view.update(cx, |this, cx| this.pick_scope(Axis::Project, &value, cx)) + }), + }; + picker(spec, window, cx) + } + + fn environment_picker(&self, window: &mut Window, cx: &mut Context) -> AnyElement { + let view = cx.entity(); + let selected = self + .scope + .machine + .as_ref() + .and_then(|id| self.data.environments.iter().find(|e| &e.id == id)); + let spec = Picker { + id: "settings-scope-environment", + label: labs::environment_trigger_label(&self.scope, &self.data), + icon: selected.map(|e| ChoiceIcon::Machine(e.machine)), + all: Some(Choice { + value: ALL.to_owned(), + label: "All environments".to_owned(), + secondary: String::new(), + offline: false, + icon: ChoiceIcon::Layers, + }), + choices: labs::environment_choices(&self.scope, &self.data), + selected: self.scope.machine.clone().unwrap_or_else(|| ALL.to_owned()), + open: self.open == Some(Axis::Environment), + search: self.environment_search.clone(), + query: self.environment_search.read(cx).value().to_string(), + on_open: { + let view = view.clone(); + Rc::new(move |opened, window, cx| { + view.update(cx, |this, cx| { + this.set_open(Axis::Environment, opened, window, cx) + }) + }) + }, + on_pick: Rc::new(move |value, _, cx| { + view.update(cx, |this, cx| { + this.pick_scope(Axis::Environment, &value, cx) + }) + }), + }; + picker(spec, window, cx) + } + + /// `SettingsScopeSentence`: `px-3 text-sm text-muted-foreground`, the two halves `gap-1.5`. + fn sentence(&self, window: &mut Window, cx: &mut Context) -> gpui::Div { + let muted = Tokens::of(cx).muted_foreground; + let project = self.project_picker(window, cx); + let environment = self.environment_picker(window, cx); + h_flex() + .min_w_0() + .flex_wrap() + .items_center() + .gap_x(px(6.)) + .gap_y(px(4.)) + .px_3() + .text_sm() + .text_color(muted) + .child( + h_flex() + .min_w_0() + .items_center() + .gap(px(6.)) + .child(div().flex_shrink_0().child("Applying settings for")) + .child(project), + ) + .child( + h_flex() + .min_w_0() + .items_center() + .gap(px(6.)) + .child(div().flex_shrink_0().child(self.scope.joiner())) + .child(environment), + ) + } +} + +impl Render for LabsPanel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let s = self.settings; + let wide = self.wide; + let rows = |items: Vec| -> Vec { + items + .into_iter() + .map(IntoElement::into_any_element) + .collect() + }; + let sentence = self.sentence(window, cx); + let beta = group( + rows(vec![ + row( + "Boring sidebar (group by project)", + "Group sidebar threads into project folders. Pinned threads stay on top, and Snoozed and Settled stay below the folders.", + None, + switch( + "settings-boring-sidebar", + s.boring_checked(), + { + let view = cx.entity(); + Rc::new(move |_, cx| { + view.update(cx, |this, cx| { + let checked = !this.settings.boring_checked(); + this.update(|s| s.set_boring(checked), cx) + }) + }) + }, + window, + cx, + ), + wide, + cx, + ), + row( + "Two-line folder rows", + "In the boring sidebar, show folder threads on two lines: the title, then the branch, pull request, and agent.", + None, + self.flag( + "settings-boring-multiline", + s.boring_multiline, + |s| s.boring_multiline = !s.boring_multiline, + window, + cx, + ), + wide, + cx, + ), + row( + "Tool calls in chat", + "Hide the commands, edits, and searches agents run. Hide successful keeps failures. Zen shows only final answers. Approvals, questions, and errors always show.", + None, + self.tool_select(cx), + wide, + cx, + ), + row( + "Guest links", + self.linked("settings-labs-guest-links", GUEST_LINKS, cx), + None, + self.server_switch("settings-guest-links", ServerKey::GuestSharing, window, cx), + wide, + cx, + ), + row( + "Linked environments", + self.linked("settings-labs-linked", LINKED_ENVIRONMENTS, cx), + None, + self.server_switch( + "settings-linked-environments", + ServerKey::LinkedEnvironments, + window, + cx, + ), + wide, + cx, + ), + row( + "Simlock device hosts (alpha)", + self.linked("settings-labs-simlock", SIMLOCK_HOSTS, cx), + None, + self.server_switch( + "settings-simlock-hosts", + ServerKey::SimlockHosts, + window, + cx, + ), + wide, + cx, + ), + ]), + cx, + ); + let legacy = group( + rows(vec![ + row( + "Plan mode (legacy)", + "Restore Build/Plan, /plan, /default, and Shift+Tab. Off uses build mode.", + None, + self.flag( + "settings-plan-mode", + s.plan_mode, + |s| s.plan_mode = !s.plan_mode, + window, + cx, + ), + wide, + cx, + ), + row( + "Context window indicator (legacy)", + "Shows context window usage as a circular indicator in the composer.", + None, + self.flag( + "settings-context-meter", + s.context_meter, + |s| s.context_meter = !s.context_meter, + window, + cx, + ), + wide, + cx, + ), + ]), + cx, + ); + v_flex() + .w_full() + .gap_8() + .on_key_down(cx.listener(|this, event, window, cx| this.key_down(event, window, cx))) + .child(sentence) + .child(section("Beta", beta)) + .child(section("Legacy", legacy)) + } +} diff --git a/apps/desktop-gpui/src/ui/settings/scope_pickers.rs b/apps/desktop-gpui/src/ui/settings/scope_pickers.rs new file mode 100644 index 000000000000..8c02b41e3f27 --- /dev/null +++ b/apps/desktop-gpui/src/ui/settings/scope_pickers.rs @@ -0,0 +1,326 @@ +//! The two pickers of the settings scope sentence: `ScopeMenu` (SettingsScopeSentence.tsx:118-226) +//! with `ProjectScopeMenu` (306-344) and `EnvironmentScopeMenu` (228-304) feeding it. A list of +//! more than six options is a combobox with a search field (`w-80`); a shorter one is a menu +//! (`w-72`) with a separator under the "all" entry. + +use super::components::tab_stop; +use super::tokens::Tokens; +use super::*; +use crate::settings_labs::{self as labs, Choice, ChoiceIcon}; +use gpui::{BoxShadow, ElementId, FocusHandle, KeyDownEvent, point}; +use std::rc::Rc; + +pub(super) type OpenHandler = Rc; +pub(super) type ChoiceHandler = Rc; + +/// What one picker draws. +pub(super) struct Picker { + pub id: &'static str, + pub label: String, + pub icon: Option, + pub all: Option, + pub choices: Vec, + pub selected: String, + pub open: bool, + pub search: Entity, + pub query: String, + pub on_open: OpenHandler, + pub on_pick: ChoiceHandler, +} + +/// `Button variant="outline" size="sm"` with `min-w-0 max-w-72`. +#[derive(IntoElement)] +struct Trigger { + id: ElementId, + label: String, + icon: Option, + handle: FocusHandle, + open: bool, + on_open: OpenHandler, +} + +impl Selectable for Trigger { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } + + fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + fn is_open(&self) -> bool { + self.open + } +} + +impl RenderOnce for Trigger { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = Tokens::of(cx); + let background = if theme.dark { + theme.input.opacity(0.32) + } else { + theme.background + }; + let on_open = self.on_open; + let hover = if theme.dark { + theme.input.opacity(0.64) + } else { + theme.accent.opacity(0.5) + }; + h_flex() + .id(self.id) + .track_focus(&self.handle) + .min_w_0() + .max_w(px(288.)) + .min_h(px(28.)) + .items_center() + .gap(px(6.)) + .px(px(9.)) + .rounded(px(8.)) + .border_1() + .border_color(theme.input) + .bg(background) + .text_sm() + .text_color(theme.foreground) + .cursor_pointer() + .hover(move |style| style.bg(hover)) + .on_key_down(move |event: &KeyDownEvent, window, cx| { + if matches!( + event.keystroke.key.as_str(), + "enter" | "space" | "down" | "up" + ) { + cx.stop_propagation(); + on_open(true, window, cx); + } + }) + .children(self.icon) + .child(div().min_w_0().truncate().child(self.label)) + .child( + div() + .mr(px(-2.)) + .flex_shrink_0() + .opacity(0.7) + .text_color(theme.muted_foreground) + .child(Icon::new(Icons::ChevronDown).size(px(14.))), + ) + } +} + +/// The icon of a choice: `size-3.5`. +fn icon(kind: &ChoiceIcon, cx: &App) -> Option { + let theme = Tokens::of(cx); + match kind { + ChoiceIcon::None => None, + ChoiceIcon::Layers => Some( + div() + .flex_shrink_0() + .child(Icon::new(Icons::Layers).size(px(14.))) + .into_any_element(), + ), + ChoiceIcon::Machine(machine) => Some( + div() + .flex_shrink_0() + .child(Icon::new(machine_icon(*machine)).size(px(14.))) + .into_any_element(), + ), + ChoiceIcon::Project { initials, color } => { + let color: gpui::Hsla = gpui::rgb(labs::avatar_color(*color, theme.dark)).into(); + let mono = cx.theme().mono_font_family.clone(); + Some( + monogram(initials, color, 14., &mono) + .text_size(px(14. * 8.25 / 16.)) + .into_any_element(), + ) + } + } +} + +/// `ScopeOptionLabel` plus the selected check (`CheckIcon size-3.5`). +fn row( + index: usize, + id: &'static str, + choice: &Choice, + picked: bool, + on_pick: &ChoiceHandler, + cx: &App, +) -> gpui::Stateful { + let theme = Tokens::of(cx); + let (accent, accent_foreground, muted) = ( + theme.accent, + theme.accent_foreground, + theme.muted_foreground, + ); + let value = choice.value.clone(); + let on_pick = on_pick.clone(); + h_flex() + .id((id, index)) + .min_h(px(28.)) + .items_center() + .gap_2() + .px_2() + .py_1() + .rounded(px(4.)) + .text_sm() + .cursor_pointer() + .hover(move |style| style.bg(accent).text_color(accent_foreground)) + .on_click(move |_, window, cx| on_pick(value.clone(), window, cx)) + .children(icon(&choice.icon, cx)) + .child( + v_flex() + .min_w_0() + .flex_1() + .child(div().truncate().child(choice.label.clone())) + .when(!choice.secondary.is_empty(), |text| { + text.child( + div() + .truncate() + .text_xs() + .text_color(muted) + .child(choice.secondary.clone()), + ) + }), + ) + .when(choice.offline, |row| { + row.child( + div() + .flex_shrink_0() + .text_xs() + .text_color(muted) + .child("Offline"), + ) + }) + .when(picked, |row| { + row.child( + div() + .flex_shrink_0() + .child(Icon::new(Icons::Check).size(px(14.))), + ) + }) +} + +/// The popup shell, as the select menu's: `dropdown-glass` on a `rounded-lg` `p-1` box. +fn shell(width: f32, cx: &App) -> gpui::Div { + let theme = Tokens::of(cx); + let shadow = if theme.dark { + (18., 44., 0.8) + } else { + (16., 40., 0.55) + }; + v_flex() + .w(px(width)) + .max_w_full() + .p_1() + .rounded(px(8.)) + .border_1() + .border_color(theme.foreground.opacity(0.1)) + .bg(theme.popover) + .shadow(vec![BoxShadow { + color: gpui::black().opacity(shadow.2), + offset: point(px(0.), px(shadow.0)), + blur_radius: px(shadow.1), + spread_radius: px(-18.), + inset: false, + }]) +} + +pub(super) fn picker(spec: Picker, window: &mut Window, cx: &mut App) -> AnyElement { + let id = ElementId::from(spec.id); + let handle = tab_stop(&id, window, cx); + let trigger = Trigger { + id: id.clone(), + label: spec.label.clone(), + icon: spec.icon.as_ref().and_then(|kind| icon(kind, cx)), + handle, + open: spec.open, + on_open: spec.on_open.clone(), + }; + let on_change = spec.on_open.clone(); + let combobox = labs::searchable(spec.choices.len()); + let popover_id = ElementId::from((spec.id, 1usize)); + Popover::new(popover_id) + .anchor(Anchor::TopLeft) + .offset(px(4.)) + .open(spec.open) + .p_0() + .appearance(false) + .on_open_change(move |opened, window, cx| on_change(*opened, window, cx)) + .trigger(trigger) + .content(move |_, _, cx| { + let mut all: Vec = spec.all.iter().cloned().collect(); + all.extend(spec.choices.iter().cloned()); + let theme = Tokens::of(cx); + let width = if combobox { 320. } else { 288. }; + let rows = |items: Vec<&Choice>| -> Vec { + let mut out = Vec::new(); + let has_all = spec.all.is_some(); + for (index, choice) in items.iter().enumerate() { + out.push( + row( + index, + spec.id, + choice, + choice.value == spec.selected, + &spec.on_pick, + cx, + ) + .into_any_element(), + ); + // The menu draws a separator under its "all" entry (`MenuSeparator`). + if !combobox && has_all && index == 0 { + out.push( + div() + .my_1() + .mx(px(-4.)) + .h(px(1.)) + .bg(theme.border) + .into_any_element(), + ); + } + } + out + }; + if !combobox { + return shell(width, cx).children(rows(all.iter().collect())); + } + let shown = labs::filter(&all, &spec.query); + let empty = shown.is_empty(); + shell(width, cx) + .child( + div() + .px_2() + .pb_1() + .border_b_1() + .border_color(theme.border) + .child( + Input::new(&spec.search) + .appearance(false) + .small() + .prefix(Icon::new(IconName::Search).size(px(14.))), + ), + ) + .child( + v_flex() + .id((spec.id, 2usize)) + .max_h(px(288.)) + .overflow_y_scroll() + .pt_1() + .children(rows(shown)) + .when(empty, |list| { + list.child( + div() + .py_2() + .text_center() + .text_sm() + .text_color(theme.muted_foreground) + .child("No matches found."), + ) + }), + ) + }) + .into_any_element() +} From b3249de825f4bf53fc73b0e5dd0684d88ab4be56 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 9 Nov 2026 02:45:21 -0500 Subject: [PATCH 65/82] feat(desktop-gpui): devices settings tab with scope inheritance indicator; shared scope bar Adds the Devices tab (Use devices, Agents can use devices with project/environment/default inheritance popover, Device sources list with Add source menu and SSH, EAS and Simlock host editors), and extracts the scope bar so Labs and Devices share it. --- apps/desktop-gpui/src/assets.rs | 4 +- apps/desktop-gpui/src/connection.rs | 26 +- apps/desktop-gpui/src/main.rs | 1 + apps/desktop-gpui/src/providers.rs | 9 + apps/desktop-gpui/src/rpc.rs | 36 + apps/desktop-gpui/src/settings.rs | 3 +- apps/desktop-gpui/src/settings_devices.rs | 1651 +++++++++++++++++ apps/desktop-gpui/src/ui.rs | 20 +- apps/desktop-gpui/src/ui/settings.rs | 59 +- .../src/ui/settings/devices_panel.rs | 1364 ++++++++++++++ .../src/ui/settings/devices_panel/editors.rs | 1008 ++++++++++ .../src/ui/settings/devices_panel/widgets.rs | 307 +++ .../src/ui/settings/labs_panel.rs | 224 +-- .../src/ui/settings/scope_inheritance.rs | 272 +++ .../src/ui/settings/scope_pickers.rs | 220 ++- 15 files changed, 4986 insertions(+), 218 deletions(-) create mode 100644 apps/desktop-gpui/src/settings_devices.rs create mode 100644 apps/desktop-gpui/src/ui/settings/devices_panel.rs create mode 100644 apps/desktop-gpui/src/ui/settings/devices_panel/editors.rs create mode 100644 apps/desktop-gpui/src/ui/settings/devices_panel/widgets.rs create mode 100644 apps/desktop-gpui/src/ui/settings/scope_inheritance.rs diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 230d53720f33..9c47255b3d56 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -90,7 +90,9 @@ gpui_kit_assets::icon_assets!( FlaskConical, Archive, PanelsTopLeft, - Undo2 + Undo2, + Apple, + Ellipsis ] ); diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 491333b38c42..3e17f1321410 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -6,13 +6,13 @@ use crate::diff; use crate::projects::MachineKind; use crate::providers::Catalog; use crate::rpc::{ - AttachmentsCreateUploadUrl, AttachmentsDelete, Event, GitRunStackedAction, - OrchestrationDispatchCommand, OrchestrationLaunchThread, PING_FRAME, ProjectsMutate, - ProviderConsumeResetCredit, ReviewGetDiffPreview, ServerGetConfig, ServerGetUsageSummary, - ServerRefreshProviders, ServerRefreshUsageRates, ServerUpdateSettings, Session, - ShellOpenInEditor, SubscribeShell, SubscribeThread, SubscribeVcsStatus, TerminalAttach, - TerminalClose, TerminalResize, TerminalWrite, VcsCreateRef, VcsListRefs, VcsPull, - VcsRefreshStatus, VcsSwitchRef, + AttachmentsCreateUploadUrl, AttachmentsDelete, DeviceConfigure, DeviceEasAccount, DeviceList, + DeviceTestHost, Event, GitRunStackedAction, OrchestrationDispatchCommand, + OrchestrationLaunchThread, PING_FRAME, ProjectsMutate, ProviderConsumeResetCredit, + ReviewGetDiffPreview, ServerGetConfig, ServerGetUsageSummary, ServerRefreshProviders, + ServerRefreshUsageRates, ServerUpdateSettings, Session, ShellOpenInEditor, SubscribeShell, + SubscribeThread, SubscribeVcsStatus, TerminalAttach, TerminalClose, TerminalResize, + TerminalWrite, VcsCreateRef, VcsListRefs, VcsPull, VcsRefreshStatus, VcsSwitchRef, }; use crate::shell::ShellState; use crate::status::{CallEvent, Status, StatusSender, publish}; @@ -130,6 +130,10 @@ pub enum Call { RefreshUsageRates, RefreshProviders, ConsumeResetCredit, + DeviceList, + DeviceConfigure, + DeviceTestHost, + DeviceEasAccount, } pub type ActionSender = async_channel::Sender; @@ -174,6 +178,10 @@ impl Call { Self::RefreshUsageRates => "refresh usage rates", Self::RefreshProviders => "refresh the provider limits", Self::ConsumeResetCredit => "use the reset credit", + Self::DeviceList => "load the devices", + Self::DeviceConfigure => "update the device settings", + Self::DeviceTestHost => "test the connection", + Self::DeviceEasAccount => "check the Expo account", } } @@ -198,6 +206,10 @@ impl Call { Self::RefreshUsageRates => session.request::(payload), Self::RefreshProviders => session.request::(payload), Self::ConsumeResetCredit => session.request::(payload), + Self::DeviceList => session.request::(payload), + Self::DeviceConfigure => session.request::(payload), + Self::DeviceTestHost => session.request::(payload), + Self::DeviceEasAccount => session.request::(payload), } } } diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 3aaba5ab4016..242056a17e1b 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -22,6 +22,7 @@ mod rpc; mod send; mod server; mod settings; +mod settings_devices; mod settings_diffs; mod settings_labs; mod shell; diff --git a/apps/desktop-gpui/src/providers.rs b/apps/desktop-gpui/src/providers.rs index f016c53069e0..1d721bc20769 100644 --- a/apps/desktop-gpui/src/providers.rs +++ b/apps/desktop-gpui/src/providers.rs @@ -363,6 +363,8 @@ pub struct Catalog { max_file_upload_bytes: Option, /// `environment.capabilities.usageModelAliases`. usage_model_aliases: bool, + /// The Devices tab's keys. + devices: crate::settings_devices::DeviceConfig, } /// The two-letter instance chip at the icon's bottom-right. @@ -449,6 +451,7 @@ impl Entry<'_> { impl Catalog { pub fn decode(value: Value) -> Result { + let devices = crate::settings_devices::DeviceConfig::from_settings(&value["settings"]); let Config { providers, settings, @@ -459,6 +462,7 @@ impl Catalog { providers, settings, available_editors, + devices, usage_model_aliases: environment .as_ref() .is_some_and(|environment| environment.capabilities.usage_model_aliases), @@ -519,6 +523,11 @@ impl Catalog { &self.available_editors } + /// The attached environment's device settings. + pub fn devices(&self) -> &crate::settings_devices::DeviceConfig { + &self.devices + } + /// The attached environment's Labs flags. pub fn labs_flags(&self) -> crate::settings_labs::ServerFlags { crate::settings_labs::ServerFlags { diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs index 93d54e19731c..1395de7701d1 100644 --- a/apps/desktop-gpui/src/rpc.rs +++ b/apps/desktop-gpui/src/rpc.rs @@ -78,6 +78,42 @@ impl Method for ServerUpdateSettings { type Success = Value; } +/// `device.list`, the Device panel's host and platform state. +pub struct DeviceList; + +impl Method for DeviceList { + const TAG: &'static str = "device.list"; + type Payload = Value; + type Success = Value; +} + +/// `device.configure`, the hub and agent-access switches. +pub struct DeviceConfigure; + +impl Method for DeviceConfigure { + const TAG: &'static str = "device.configure"; + type Payload = Value; + type Success = Value; +} + +/// `device.testHost`, a connection check of an SSH host. +pub struct DeviceTestHost; + +impl Method for DeviceTestHost { + const TAG: &'static str = "device.testHost"; + type Payload = Value; + type Success = Value; +} + +/// `device.easAccount`, the Expo account behind an EAS host. +pub struct DeviceEasAccount; + +impl Method for DeviceEasAccount { + const TAG: &'static str = "device.easAccount"; + type Payload = Value; + type Success = Value; +} + /// `attachments.createUploadUrl`, which mints the signed URL an attachment is posted to. pub struct AttachmentsCreateUploadUrl; diff --git a/apps/desktop-gpui/src/settings.rs b/apps/desktop-gpui/src/settings.rs index b9a0b68a0aaf..af50bee79306 100644 --- a/apps/desktop-gpui/src/settings.rs +++ b/apps/desktop-gpui/src/settings.rs @@ -653,7 +653,7 @@ fn is_combining(ch: char) -> bool { } /// Every searchable setting of the tabs that have a panel, in the web's order -/// (`settingsSearch.ts`): General, Labs, Diffs, then Chat. +/// (`settingsSearch.ts`): General, Labs, Diffs, Chat, then Devices. pub fn searchable() -> &'static [SearchItem] { static ITEMS: std::sync::OnceLock> = std::sync::OnceLock::new(); ITEMS.get_or_init(|| { @@ -662,6 +662,7 @@ pub fn searchable() -> &'static [SearchItem] { .chain(crate::settings_labs::LABS_ITEMS) .chain(crate::settings_diffs::DIFFS_ITEMS) .chain(CHAT_ITEMS) + .chain(crate::settings_devices::DEVICE_ITEMS) .copied() .collect() }) diff --git a/apps/desktop-gpui/src/settings_devices.rs b/apps/desktop-gpui/src/settings_devices.rs new file mode 100644 index 000000000000..a0556416adf3 --- /dev/null +++ b/apps/desktop-gpui/src/settings_devices.rs @@ -0,0 +1,1651 @@ +//! The Devices tab (`DeviceIntegrationSettings`, IntegrationsSettings.tsx:804-1030, and +//! `DeviceSourcesSettings.tsx`): its server settings, the device service state, the inheritance +//! chain of a row and the host lists. The panel only draws this. +//! +//! Where each key lives (packages/contracts/src/settings.ts, device.ts): +//! - `enableDeviceSupport` is environment-wide: it is not in +//! `PROJECT_SCOPED_SERVER_SETTING_KEYS`, so a project scope cannot write it. +//! - `enableAgentDeviceAccess` is project-scoped. A project override lives in +//! `projectSettingsOverrides[projectId].enableAgentDeviceAccess`, and the effective value is the +//! override, else the environment's, else `false` (`DEFAULT_SERVER_SETTINGS`). No `t3.json` +//! layer exists for it (`PROJECT_FILE_BACKED_SETTINGS`). +//! - `deviceHosts`, `easDeviceHosts` and `simlockDeviceHosts` are environment-wide lists, written +//! with `server.updateSettings`. The hub and agent switches write through `device.configure` +//! outside a project scope, because the server starts or stops the hub with them. +//! - The switches above and the tools' versions describe the local host: `hosts[kind=local].tools` +//! of the `DeviceServiceState` that `device.list` returns. + +use crate::settings::{SearchItem, Section}; +use serde::{Deserialize, Deserializer, Serialize, de::DeserializeOwned}; +use serde_json::{Map, Value, json}; +use std::collections::HashMap; + +/// Call tokens of the Devices tab's requests start here. +pub const TOKEN_BASE: u64 = 1 << 45; + +const fn item(id: &'static str, title: &'static str, terms: &'static str) -> SearchItem { + SearchItem { + id, + title, + section: Section::Devices, + terms, + desktop_only: false, + } +} + +/// The Devices tab's `searchableSetting` entries (settingsSearch.ts:845-872). +pub const DEVICE_ITEMS: &[SearchItem] = &[ + item( + "device-sources", + "Device sources", + "device hosts ssh remote mac mini identity key connection eas expo cloud simlock pool gateway local simulators", + ), + item( + "agent-device-access", + "Agents can use devices", + "allow simulator emulator ios android drive tools sessions", + ), + item( + "device-hub", + "Use devices", + "device hub simulator emulator ios android install start version", + ), + item( + "device-platform-support", + "Local simulators", + "simulator support xcode android studio sdk avd runtime", + ), +]; + +/// The two switches of the Devices card. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Key { + Hub, + Agent, +} + +impl Key { + pub fn wire(self) -> &'static str { + match self { + Self::Hub => "enableDeviceSupport", + Self::Agent => "enableAgentDeviceAccess", + } + } + + /// `isProjectScopedSettingKey`. + pub fn project_scoped(self) -> bool { + matches!(self, Self::Agent) + } +} + +/// `lenient`: a host that does not decode is dropped, as the web drops an undecodable entry. +fn lenient<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: DeserializeOwned, +{ + let items = Value::deserialize(deserializer)?; + Ok(match items { + Value::Array(items) => items + .into_iter() + .filter_map(|item| serde_json::from_value(item).ok()) + .collect(), + _ => Vec::new(), + }) +} + +fn ssh_kind() -> String { + "ssh".to_owned() +} + +/// `SshDeviceHostConfig` (device.ts:21-30). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SshHost { + #[serde(default = "ssh_kind")] + pub kind: String, + pub id: String, + pub label: String, + pub target: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub identity_file: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub port: Option, +} + +/// `EasDeviceHostConfig` (device.ts:32-67). The token is kept as the server sent it: a saved +/// token arrives redacted and sending it back keeps it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EasHost { + pub kind: String, + pub id: String, + pub label: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub app_id: Option, + #[serde(default)] + pub access_token: Value, + pub max_session_minutes: u32, + pub idle_minutes: u32, + pub max_concurrent_sessions: u32, +} + +/// `SimlockDeviceHostConfig` (device.ts:69-90). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SimlockHost { + pub kind: String, + pub id: String, + pub label: String, + pub gateway_url: String, + #[serde(default)] + pub agent_token: Value, + pub max_leases: u32, + pub max_lease_minutes: u32, +} + +/// The device keys of `server.getConfig`'s `settings`. +#[derive(Clone, Debug, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DeviceConfig { + #[serde(default)] + pub enable_device_support: bool, + #[serde(default)] + pub enable_agent_device_access: bool, + #[serde(default)] + pub simlock_device_hosts_enabled: bool, + #[serde(default, deserialize_with = "lenient")] + pub device_hosts: Vec, + #[serde(default, deserialize_with = "lenient")] + pub eas_device_hosts: Vec, + #[serde(default, deserialize_with = "lenient")] + pub simlock_device_hosts: Vec, + /// Each project's whole override entry: a write replaces it, so every key is kept. + #[serde(default, deserialize_with = "overrides")] + pub project_settings_overrides: HashMap>, +} + +fn overrides<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let value = Value::deserialize(deserializer)?; + Ok(match value { + Value::Object(entries) => entries + .into_iter() + .filter_map(|(id, entry)| match entry { + Value::Object(entry) => Some((id, entry)), + _ => None, + }) + .collect(), + _ => HashMap::new(), + }) +} + +impl DeviceConfig { + /// Reads the device keys out of a `settings` object; anything else gives the defaults. + pub fn from_settings(settings: &Value) -> Self { + serde_json::from_value(settings.clone()).unwrap_or_default() + } + + pub fn environment_value(&self, key: Key) -> bool { + match key { + Key::Hub => self.enable_device_support, + Key::Agent => self.enable_agent_device_access, + } + } + + /// The project's override of the key, when it has one. + pub fn project_override(&self, key: Key, project: &str) -> Option { + if !key.project_scoped() { + return None; + } + self.project_settings_overrides + .get(project)? + .get(key.wire())? + .as_bool() + } + + /// `resolveProjectSettings` for the one key: the override, else the environment's value. + #[cfg(test)] + pub fn effective(&self, key: Key, project: Option<&str>) -> bool { + project + .and_then(|project| self.project_override(key, project)) + .unwrap_or_else(|| self.environment_value(key)) + } +} + +/// Which layer an inheritance row is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LayerKind { + Project, + Environment, + Default, +} + +/// `InheritanceLayer` (SettingInheritance.tsx:29-35). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Layer { + pub kind: LayerKind, + pub label: String, + pub value: String, + pub effective: bool, + pub set: bool, +} + +fn on_off(value: bool) -> String { + if value { "On" } else { "Off" }.to_owned() +} + +/// `settingInheritanceLayers` (SettingInheritance.tsx:105-147) for a boolean key whose default +/// is `false`. `project` is the project scope's override, `None` outside a project scope. +pub fn layers( + key: Key, + environment_label: &str, + in_project: bool, + project_override: Option, + environment: bool, +) -> Vec { + let from_project = key.project_scoped() && project_override.is_some(); + let environment_set = environment; + let mut out = Vec::new(); + if in_project && key.project_scoped() { + out.push(Layer { + kind: LayerKind::Project, + label: "Project".to_owned(), + value: match project_override { + Some(value) => on_off(value), + None => "Inherits".to_owned(), + }, + effective: from_project, + set: from_project, + }); + } + out.push(Layer { + kind: LayerKind::Environment, + label: environment_label.to_owned(), + value: if environment_set { + on_off(environment) + } else { + "Inherits".to_owned() + }, + effective: !from_project && environment_set, + set: environment_set, + }); + out.push(Layer { + kind: LayerKind::Default, + label: "Default".to_owned(), + value: on_off(false), + effective: !from_project && !environment_set, + set: true, + }); + out +} + +/// `SettingInheritanceState` (SettingInheritance.tsx:149-154). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum State { + Default, + Environment, + Inherited, + Overridden, +} + +/// The tooltip and marker state of a row (settingsLayout.tsx:381-395). `environment_name` is the +/// selected environment's label, or "environment" when several are in scope. +pub fn inheritance( + key: Key, + in_project: bool, + project_override: Option, + environment: bool, + environment_name: &str, +) -> (State, String) { + if in_project && key.project_scoped() { + return if project_override.is_some() { + (State::Overridden, "Overridden for this project".to_owned()) + } else { + ( + State::Inherited, + format!("Inherited from {environment_name}"), + ) + }; + } + if environment { + (State::Environment, "Set on the environment".to_owned()) + } else { + (State::Default, "Built-in default".to_owned()) + } +} + +/// The marker waits for hover or focus unless it explains something (settingsLayout.tsx:455-459). +pub fn marker_always_visible(state: State) -> bool { + !matches!(state, State::Default | State::Environment) +} + +/// `server.updateSettings` payload that sets a project override of the agent switch: the whole +/// entry is replaced, so the other keys of it ride along. +pub fn override_patch( + config: &DeviceConfig, + project: &str, + key: Key, + value: Option, +) -> Value { + let mut entry = config + .project_settings_overrides + .get(project) + .cloned() + .unwrap_or_default(); + match value { + Some(value) => { + entry.insert(key.wire().to_owned(), json!(value)); + } + None => { + entry.remove(key.wire()); + } + } + let entry = if entry.is_empty() { + Value::Null + } else { + Value::Object(entry) + }; + json!({"patch": {"projectSettingsOverrides": {project: entry}}}) +} + +/// `device.configure` for a switch: turning the hub on completes onboarding, and turning it off +/// also turns off agent access (IntegrationsSettings.tsx:850-852, 932-939). +pub fn configure_payload(key: Key, checked: bool) -> Value { + match key { + Key::Hub => { + let mut payload = json!({"enabled": checked}); + if checked { + payload["onboardingCompleted"] = json!(true); + } else { + payload["agentAccessEnabled"] = json!(false); + } + payload + } + Key::Agent => json!({"agentAccessEnabled": checked}), + } +} + +/// `DeviceHostStatus` (device.ts:223-231). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum HostStatus { + Disabled, + Idle, + Installing, + Starting, + Ready, + Failed, +} + +impl HostStatus { + fn parse(value: &str) -> Option { + Some(match value { + "disabled" => Self::Disabled, + "idle" => Self::Idle, + "installing" => Self::Installing, + "starting" => Self::Starting, + "ready" => Self::Ready, + "failed" => Self::Failed, + _ => return None, + }) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostState { + pub status: HostStatus, + pub detail: Option, + pub android_incomplete: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Platform { + Ios, + Android, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Availability { + pub platform: Platform, + pub available: bool, + pub reason: Option, +} + +/// `DeviceToolVersion` (device.ts:206-210). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ToolVersion { + pub required: String, + pub installed: Vec, + pub running: Option, +} + +impl ToolVersion { + /// The version shown: the running one, else the required one when installed, else the + /// highest installed (DeviceToolVersions.tsx:21-30). + pub fn version(&self) -> Option { + if let Some(running) = &self.running { + return Some(running.clone()); + } + if self.installed.contains(&self.required) { + return Some(self.required.clone()); + } + let mut sorted = self.installed.clone(); + sorted.sort_by(|a, b| numeric_order(a, b)); + sorted.pop() + } + + pub fn needs_update(&self) -> bool { + !self.installed.contains(&self.required) + } +} + +/// `localeCompare(b, undefined, {numeric: true})` for dotted versions. +fn numeric_order(a: &str, b: &str) -> std::cmp::Ordering { + let parts = |text: &str| -> Vec { + let mut out = Vec::new(); + let mut current = String::new(); + let mut digits = None; + for ch in text.chars() { + let is_digit = ch.is_ascii_digit(); + if digits.is_some_and(|d| d != is_digit) { + out.push(std::mem::take(&mut current)); + } + digits = Some(is_digit); + current.push(ch); + } + out.push(current); + out + }; + let (left, right) = (parts(a), parts(b)); + for (x, y) in left.iter().zip(right.iter()) { + let order = match (x.parse::(), y.parse::()) { + (Ok(x), Ok(y)) => x.cmp(&y), + _ => x.cmp(y), + }; + if order != std::cmp::Ordering::Equal { + return order; + } + } + left.len().cmp(&right.len()) +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Tools { + pub hub: ToolVersion, + pub agent: ToolVersion, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tool { + Hub, + Agent, +} + +impl Tool { + /// `updateTool` of `device.list`. + pub fn wire(self) -> &'static str { + match self { + Self::Hub => "hub", + Self::Agent => "agent", + } + } + + pub fn name(self) -> &'static str { + match self { + Self::Hub => "Device hub", + Self::Agent => "Agent device", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostSummary { + pub id: String, + pub kind: String, + pub platforms: Vec, + pub tools: Option, +} + +/// `DeviceServiceState` (device.ts:253-296), reduced to what the tab reads. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct DeviceState { + pub supports_host_retry: bool, + pub supports_tool_update: bool, + pub supports_tool_inspection: bool, + pub supports_eas_hosts: bool, + pub supports_simlock_hosts: bool, + pub host_status: Option, + pub host_statuses: HashMap, + pub hosts: Vec, +} + +fn string(value: &Value, key: &str) -> Option { + value.get(key)?.as_str().map(str::to_owned) +} + +fn flag(value: &Value, key: &str) -> bool { + value.get(key).and_then(Value::as_bool).unwrap_or(false) +} + +fn tool_version(value: &Value) -> Option { + Some(ToolVersion { + required: string(value, "requiredVersion")?, + installed: value + .get("installedVersions")? + .as_array()? + .iter() + .filter_map(|v| v.as_str().map(str::to_owned)) + .collect(), + running: string(value, "runningVersion"), + }) +} + +pub fn host_summary(value: &Value) -> Option { + let platforms = value + .get("platforms") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(|item| { + let platform = match item.get("platform")?.as_str()? { + "ios" => Platform::Ios, + "android" => Platform::Android, + _ => return None, + }; + Some(Availability { + platform, + available: flag(item, "available"), + reason: string(item, "reason"), + }) + }) + .collect() + }) + .unwrap_or_default(); + let tools = value.get("tools").and_then(|tools| { + Some(Tools { + hub: tool_version(tools.get("hub")?)?, + agent: tool_version(tools.get("agent")?)?, + }) + }); + Some(HostSummary { + id: string(value, "id")?, + kind: string(value, "kind")?, + platforms, + tools, + }) +} + +impl DeviceState { + /// Decodes `device.list` and `device.configure` results. The wire form keeps EAS and Simlock + /// summaries apart; decoded, every host is in `hosts` (device.ts:304-306). + pub fn parse(value: &Value) -> Self { + let hosts = ["hosts", "easHosts", "simlockHosts"] + .iter() + .filter_map(|key| value.get(*key)?.as_array()) + .flatten() + .filter_map(host_summary) + .collect(); + let host_statuses = value + .get("hostStatuses") + .and_then(Value::as_object) + .map(|statuses| { + statuses + .iter() + .filter_map(|(id, status)| { + Some(( + id.clone(), + HostState { + status: HostStatus::parse(status.get("status")?.as_str()?)?, + detail: string(status, "detail"), + android_incomplete: flag(status, "androidDiscoveryIncomplete"), + }, + )) + }) + .collect() + }) + .unwrap_or_default(); + Self { + supports_host_retry: flag(value, "supportsHostRetry"), + supports_tool_update: flag(value, "supportsToolUpdate"), + supports_tool_inspection: flag(value, "supportsToolInspection"), + supports_eas_hosts: flag(value, "supportsEasHosts"), + supports_simlock_hosts: flag(value, "supportsSimlockHosts"), + host_status: value + .get("hostStatus") + .and_then(Value::as_str) + .and_then(HostStatus::parse), + host_statuses, + hosts, + } + } + + pub fn local(&self) -> Option<&HostSummary> { + self.hosts.iter().find(|host| host.kind == "local") + } + + /// `deviceHubReadiness` (DeviceSetup.tsx:36-45): whether a hub is installing or starting + /// with none ready. + pub fn busy(&self) -> bool { + let statuses = self.host_statuses.values().map(|host| host.status); + let all: Vec = self.host_status.into_iter().chain(statuses).collect(); + let ready = all.contains(&HostStatus::Ready); + !ready + && all + .iter() + .any(|s| matches!(s, HostStatus::Installing | HostStatus::Starting)) + } + + /// `DeviceToolVersions`'s trigger text for a tool of the local host. + pub fn tool_label(&self, tool: Tool) -> String { + if self.local().and_then(|host| host.tools.as_ref()).is_none() { + return "Version unknown".to_owned(); + } + match self.tool(tool).and_then(ToolVersion::version) { + Some(version) => format!("v{version}"), + None => "Not installed".to_owned(), + } + } + + pub fn tool(&self, tool: Tool) -> Option<&ToolVersion> { + let tools = self.local()?.tools.as_ref()?; + Some(match tool { + Tool::Hub => &tools.hub, + Tool::Agent => &tools.agent, + }) + } +} + +/// `SourceStatus` tones (DeviceSourcesSettings.tsx:58-63). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tone { + Pending, + Warning, + Error, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SourceStatus { + pub tone: Tone, + pub label: String, + pub detail: Option, + pub action: Option, +} + +/// The action offered next to a failed source (DeviceSourcesSettings.tsx:269-330). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StatusAction { + /// `device.list {}`. + Refresh, + /// `device.list {retryHostId}`. + Retry(String), +} + +/// `hostStatus` (DeviceSourcesSettings.tsx:109-126): what a configured host is doing, when it is +/// not simply working. +pub fn host_status(state: Option<&DeviceState>, host_id: &str) -> Option { + let Some(state) = state else { + return Some(SourceStatus { + tone: Tone::Pending, + label: "Loading".to_owned(), + detail: None, + action: None, + }); + }; + let status = state.host_statuses.get(host_id)?; + let detail = status.detail.clone(); + match status.status { + HostStatus::Ready if status.android_incomplete => Some(SourceStatus { + tone: Tone::Warning, + label: "Android list may be incomplete".to_owned(), + detail, + action: None, + }), + HostStatus::Installing | HostStatus::Starting => Some(SourceStatus { + tone: Tone::Pending, + label: "Starting".to_owned(), + detail, + action: None, + }), + HostStatus::Failed => Some(SourceStatus { + tone: Tone::Error, + label: "Failed".to_owned(), + detail, + action: None, + }), + _ => None, + } +} + +/// The kind of a source row. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Source { + Local, + Ssh(SshHost), + Eas(EasHost), + Simlock(SimlockHost), +} + +/// One row of the Device sources card, as `DeviceSourcesSettings` builds it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Row { + pub key: String, + pub source: Source, + pub title: String, + pub kind: Option<&'static str>, + pub description: String, + pub status: Option, + pub platforms: Vec, +} + +/// `easSessionLimits` (easDeviceHostsSettings.logic.ts:38-42). +pub fn eas_session_limits(host: &EasHost) -> String { + let sessions = if host.max_concurrent_sessions == 1 { + "1 session".to_owned() + } else { + format!("{} sessions", host.max_concurrent_sessions) + }; + format!( + "Up to {sessions} at once, each stopped after {} min or {} idle min", + host.max_session_minutes, host.idle_minutes + ) +} + +/// `simlockLeaseLimits` (simlockDeviceHostsSettings.logic.ts:46-49). +pub fn simlock_lease_limits(host: &SimlockHost) -> String { + let devices = if host.max_leases == 1 { + "1 device".to_owned() + } else { + format!("{} devices", host.max_leases) + }; + format!( + "Up to {devices} at once, each released after {} min", + host.max_lease_minutes + ) +} + +/// The rows of the Device sources card for the attached environment, in the web's order: the +/// local source, the SSH hosts, EAS hosts when the server supports them, and Simlock hosts when +/// it supports them and the Labs switch is on (DeviceSourcesSettings.tsx:269-455). +pub fn rows( + config: &DeviceConfig, + state: Option<&DeviceState>, + environments: usize, + active: bool, +) -> Vec { + let platforms = |id: &str| -> Vec { + if !active { + return Vec::new(); + } + state + .and_then(|state| state.hosts.iter().find(|host| host.id == id)) + .map(|host| host.platforms.clone()) + .unwrap_or_default() + }; + let mut rows = Vec::new(); + let local = state.and_then(DeviceState::local); + rows.push(Row { + key: "local".to_owned(), + source: Source::Local, + title: "Local simulators".to_owned(), + kind: None, + description: if environments > 1 { + "Simulators and emulators installed on each environment's own machine." + } else { + "Simulators and emulators installed on this environment's machine." + } + .to_owned(), + status: active + .then(|| host_status(state, local.map_or("", |host| host.id.as_str()))) + .flatten() + .map(|mut status| { + if status.tone == Tone::Error { + status.action = Some(StatusAction::Refresh); + } + status + }), + platforms: if active { + local.map(|host| host.platforms.clone()).unwrap_or_default() + } else { + Vec::new() + }, + }); + for host in &config.device_hosts { + let status = active + .then(|| host_status(state, &host.id)) + .flatten() + .map(|mut status| { + if status.tone == Tone::Error && state.is_some_and(|s| s.supports_host_retry) { + status.label = "Can't connect".to_owned(); + status.action = Some(StatusAction::Retry(host.id.clone())); + } + status + }); + rows.push(Row { + key: format!("ssh:{}", host.id), + source: Source::Ssh(host.clone()), + title: host.label.clone(), + kind: Some("SSH"), + description: host.target.clone(), + status, + platforms: platforms(&host.id), + }); + } + if state.is_some_and(|s| s.supports_eas_hosts) { + for host in &config.eas_device_hosts { + rows.push(Row { + key: format!("eas:{}", host.id), + source: Source::Eas(host.clone()), + title: host.label.clone(), + kind: Some("EAS"), + description: eas_session_limits(host), + status: active.then(|| host_status(state, &host.id)).flatten(), + platforms: platforms(&host.id), + }); + } + } + if state.is_some_and(|s| s.supports_simlock_hosts) && config.simlock_device_hosts_enabled { + for host in &config.simlock_device_hosts { + let url = host + .gateway_url + .trim_start_matches("https://") + .trim_start_matches("http://"); + rows.push(Row { + key: format!("simlock:{}", host.id), + source: Source::Simlock(host.clone()), + title: host.label.clone(), + kind: Some("Simlock"), + description: format!("{url} \u{b7} {}", simlock_lease_limits(host)), + status: active.then(|| host_status(state, &host.id)).flatten(), + platforms: platforms(&host.id), + }); + } + } + rows +} + +/// The items of the Add source menu (DeviceSourcesSettings.tsx:521-557). +pub fn add_menu( + state: Option<&DeviceState>, + simlock_enabled: bool, +) -> Vec<(AddKind, &'static str)> { + let mut items = vec![(AddKind::Ssh, "Machine over SSH")]; + if state.is_some_and(|s| s.supports_eas_hosts) { + items.push((AddKind::Eas, "EAS Simulator")); + } + if state.is_some_and(|s| s.supports_simlock_hosts) && simlock_enabled { + items.push((AddKind::Simlock, "Simlock pool (alpha)")); + } + items +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AddKind { + Ssh, + Eas, + Simlock, +} + +/// A host id the server accepts (`ConfiguredDeviceHostId`): an alphanumeric first character, +/// then letters, digits, `_` and `-`, never `local`. +pub fn new_host_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()); + format!("h{:x}-{:x}", nanos, COUNTER.fetch_add(1, Ordering::Relaxed)) +} + +pub fn valid_host_id(id: &str) -> bool { + let mut chars = id.chars(); + id != "local" + && id.len() <= 128 + && chars.next().is_some_and(|c| c.is_ascii_alphanumeric()) + && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +pub fn new_eas_host(id: String) -> EasHost { + EasHost { + kind: "eas".to_owned(), + id, + label: "EAS Simulator".to_owned(), + app_id: None, + access_token: json!(""), + max_session_minutes: 30, + idle_minutes: 10, + max_concurrent_sessions: 2, + } +} + +pub fn new_simlock_host(id: String) -> SimlockHost { + SimlockHost { + kind: "simlock".to_owned(), + id, + label: "Simlock".to_owned(), + gateway_url: String::new(), + agent_token: json!(""), + max_leases: 2, + max_lease_minutes: 60, + } +} + +/// `parseDeviceHostDraft` (deviceHostConnectionChecks.ts:23-29): the host the form describes, +/// trimmed, or `None` when the server would reject it. +pub fn parse_ssh_draft(host: &SshHost) -> Option { + let label = host.label.trim(); + let target = host.target.trim(); + let identity = host + .identity_file + .as_deref() + .map(str::trim) + .filter(|file| !file.is_empty()); + let target_ok = + !target.is_empty() && !target.starts_with('-') && !target.chars().any(char::is_whitespace); + let port_ok = host.port.is_none_or(|port| port >= 1); + (valid_host_id(&host.id) && !label.is_empty() && target_ok && port_ok).then(|| SshHost { + kind: "ssh".to_owned(), + id: host.id.clone(), + label: label.to_owned(), + target: target.to_owned(), + identity_file: identity.map(str::to_owned), + port: host.port, + }) +} + +/// The host a connection test or save checks: the label falls back to the target while it is +/// blank, so the check can run before the name is typed (DeviceHostEditor.tsx). +pub fn ssh_test_host(draft: &SshHost) -> Option { + let mut host = draft.clone(); + if host.label.trim().is_empty() { + host.label = host.target.clone(); + } + parse_ssh_draft(&host) +} + +/// `updateDeviceHosts` (deviceHostsSettings.logic.ts:56-85) for a single environment: replaces +/// the host with the same id, or the one at the same SSH destination, or appends. +pub fn update_ssh_hosts( + hosts: &[SshHost], + host: &SshHost, + remove: bool, + original: &SshHost, +) -> Result, String> { + let same_destination = |a: &SshHost, b: &SshHost| { + a.target == b.target && a.port == b.port && a.identity_file == b.identity_file + }; + let find_destination = |destination: &SshHost| -> Result, String> { + let matches: Vec<&SshHost> = hosts + .iter() + .filter(|candidate| same_destination(candidate, destination)) + .collect(); + if matches.len() > 1 { + return Err( + "Multiple hosts match this SSH destination. Select the environment to edit its hosts." + .to_owned(), + ); + } + Ok(matches.first().copied()) + }; + let existing = match hosts.iter().find(|candidate| candidate.id == original.id) { + Some(found) => Some(found), + None => match find_destination(original)? { + Some(found) => Some(found), + None if remove => None, + None => find_destination(host)?, + }, + }; + if remove { + return Ok(hosts + .iter() + .filter(|candidate| Some(candidate.id.as_str()) != existing.map(|e| e.id.as_str())) + .cloned() + .collect()); + } + Ok(match existing { + Some(existing) => hosts + .iter() + .map(|candidate| { + if candidate.id == existing.id { + SshHost { + id: existing.id.clone(), + ..host.clone() + } + } else { + candidate.clone() + } + }) + .collect(), + None => hosts.iter().cloned().chain([host.clone()]).collect(), + }) +} + +/// `updateEasDeviceHosts` (easDeviceHostsSettings.logic.ts:20-31). +pub fn update_eas_hosts( + hosts: &[EasHost], + host: &EasHost, + remove: bool, + keep_token: bool, +) -> Vec { + if remove { + return hosts.iter().filter(|c| c.id != host.id).cloned().collect(); + } + let existing = hosts.iter().find(|c| c.id == host.id); + let mut next = host.clone(); + if keep_token { + next.access_token = existing.map_or_else(|| json!(""), |e| e.access_token.clone()); + } + match existing { + Some(_) => hosts + .iter() + .map(|c| { + if c.id == host.id { + next.clone() + } else { + c.clone() + } + }) + .collect(), + None => hosts.iter().cloned().chain([next]).collect(), + } +} + +/// `updateSimlockDeviceHosts` (simlockDeviceHostsSettings.logic.ts:20-28). +pub fn update_simlock_hosts( + hosts: &[SimlockHost], + host: &SimlockHost, + remove: bool, +) -> Vec { + if remove { + return hosts.iter().filter(|c| c.id != host.id).cloned().collect(); + } + if hosts.iter().any(|c| c.id == host.id) { + hosts + .iter() + .map(|c| { + if c.id == host.id { + host.clone() + } else { + c.clone() + } + }) + .collect() + } else { + hosts.iter().cloned().chain([host.clone()]).collect() + } +} + +/// EAS limits the form's ranges allow (device.ts:48-67): the label is set, sessions 1..=20, +/// session minutes 1..=115, idle minutes 1..=60, and the idle limit is below the session limit. +/// Returns why Save is off. +pub fn eas_problem(host: &EasHost) -> Option<&'static str> { + if host.max_session_minutes > 0 && host.idle_minutes >= host.max_session_minutes { + return Some("Idle minutes must be smaller than session minutes."); + } + let ok = !host.label.trim().is_empty() + && (1..=115).contains(&host.max_session_minutes) + && (1..=60).contains(&host.idle_minutes) + && (1..=20).contains(&host.max_concurrent_sessions); + (!ok).then_some("Give the host a label and limits within the ranges shown.") +} + +/// `simlockDeviceHostProblem` (simlockDeviceHostsSettings.logic.ts:30-44). +pub fn simlock_problem(host: &SimlockHost) -> Option<&'static str> { + if host.label.trim().is_empty() { + return Some("Give the host a label."); + } + if !valid_gateway(host.gateway_url.trim()) { + return Some("Enter the gateway's origin, such as https://gateway.example.ts.net:4700."); + } + let limits = (1..=20).contains(&host.max_leases) && (5..=240).contains(&host.max_lease_minutes); + (!limits).then_some("Keep the limits within the ranges shown.") +} + +/// `^https?:\/\/[^\s/]+\/?$`. +fn valid_gateway(url: &str) -> bool { + let rest = url + .strip_prefix("https://") + .or_else(|| url.strip_prefix("http://")); + let Some(rest) = rest else { + return false; + }; + let host = rest.strip_suffix('/').unwrap_or(rest); + !host.is_empty() && !host.chars().any(|c| c.is_whitespace() || c == '/') +} + +/// `server.updateSettings` payload for one host list. +pub fn hosts_patch(key: &str, hosts: &[T]) -> Value { + json!({"patch": {key: hosts}}) +} + +/// `device.testHost` payload (an `SshDeviceHostConfig`). +pub fn test_payload(host: &SshHost) -> Value { + serde_json::to_value(host).unwrap_or(Value::Null) +} + +/// Why the Add source and Edit controls are off (DeviceSourcesSettings.tsx:152-156): a project +/// scope, a write in flight, or no connected environment. +pub fn sources_disabled(in_project: bool, busy: bool, connected: bool) -> bool { + in_project || busy || !connected +} + +/// The scope's project keys to the environment's project ids that hold the overrides. +pub type ProjectIds = HashMap>; + +/// Project scope key (`logical_key`, else `key`) to every project of the group. +pub fn project_ids(entries: &[crate::projects::ProjectEntry]) -> ProjectIds { + let mut map = ProjectIds::new(); + for entry in entries.iter().filter(|e| !e.scratch) { + let key = entry + .logical_key + .clone() + .unwrap_or_else(|| entry.key.clone()); + map.entry(key) + .or_default() + .extend(entry.member_ids.iter().cloned()); + } + map +} + +/// Merges the `projectSettingsOverrides` entries of several patches into one. +pub fn merge_override_patches(patches: Vec) -> Value { + let mut merged = Map::new(); + for patch in patches { + if let Some(entries) = patch["patch"]["projectSettingsOverrides"].as_object() { + merged.extend(entries.clone()); + } + } + json!({"patch": {"projectSettingsOverrides": merged}}) +} + +/// Why a switch of the tab is inert, or that its write reaches the attached environment +/// (settingsLayout.tsx:405-448). The hub is environment-wide; the agent switch is project-scoped. +pub fn reach( + key: Key, + search: &crate::settings_labs::ScopeSearch, + data: &crate::settings_labs::ScopeData, +) -> Result<(), String> { + use crate::settings_labs::{Scope, resolve}; + let writable = |id: &str| { + data.environments + .iter() + .any(|e| e.id == id && e.connected && e.writable) + }; + let reconnect = || "Reconnect the selected environment to change this setting.".to_owned(); + match resolve(search, data) { + Scope::Unavailable(message) => Err(message.to_owned()), + Scope::Project(_) if !key.project_scoped() => { + Err("Environment-wide setting. Select an environment to change it.".to_owned()) + } + Scope::Project(ids) => ids + .iter() + .any(|id| writable(id)) + .then_some(()) + .ok_or_else(reconnect), + Scope::Environment(id) => match data.environments.iter().find(|e| e.id == id) { + Some(e) if !e.connected => Err(reconnect()), + Some(e) if !e.writable => Err(format!( + "Settings of {} cannot be changed from this app yet.", + e.label + )), + _ => Ok(()), + }, + Scope::All => data + .environments + .iter() + .any(|e| e.connected && e.writable) + .then_some(()) + .ok_or_else(reconnect), + } +} + +impl DeviceState { + /// The pending status beside a switch (DeviceSetup.tsx:271-361, compact). + pub fn setup_label(&self) -> &'static str { + match self.host_status { + Some(HostStatus::Installing) => "Installing\u{2026}", + Some(HostStatus::Starting) => "Starting\u{2026}", + _ => "Updating\u{2026}", + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ssh(id: &str, target: &str) -> SshHost { + SshHost { + kind: "ssh".to_owned(), + id: id.to_owned(), + label: format!("Host {id}"), + target: target.to_owned(), + identity_file: None, + port: None, + } + } + + #[test] + fn config_reads_the_device_keys_and_drops_bad_hosts() { + let config = DeviceConfig::from_settings(&json!({ + "enableDeviceSupport": true, + "deviceHosts": [ + {"id": "a", "label": "A", "target": "user@example"}, + {"id": 3}, + ], + "easDeviceHosts": [{ + "kind": "eas", "id": "e", "label": "E", "accessToken": "redacted", + "maxSessionMinutes": 30, "idleMinutes": 10, "maxConcurrentSessions": 2 + }], + "projectSettingsOverrides": { + "p1": {"enableAgentDeviceAccess": true, "defaultRuntimeMode": "full-access"}, + "p2": null + } + })); + assert!(config.enable_device_support); + assert_eq!( + config.device_hosts, + vec![ssh("a", "user@example").tap(|h| h.label = "A".into())] + ); + assert_eq!(config.eas_device_hosts.len(), 1); + assert_eq!(config.project_override(Key::Agent, "p1"), Some(true)); + assert_eq!(config.project_override(Key::Agent, "p2"), None); + assert_eq!(config.project_override(Key::Hub, "p1"), None); + } + + trait Tap: Sized { + fn tap(mut self, change: impl FnOnce(&mut Self)) -> Self { + change(&mut self); + self + } + } + impl Tap for SshHost {} + + #[test] + fn effective_value_prefers_the_project_override() { + let config = DeviceConfig::from_settings(&json!({ + "enableAgentDeviceAccess": true, + "projectSettingsOverrides": {"p": {"enableAgentDeviceAccess": false}} + })); + assert!(!config.effective(Key::Agent, Some("p"))); + assert!(config.effective(Key::Agent, Some("other"))); + assert!(config.effective(Key::Agent, None)); + } + + #[test] + fn layers_mark_the_effective_one() { + let inherited = layers(Key::Agent, "Example Mac", true, None, true); + assert_eq!( + inherited + .iter() + .map(|l| (l.kind, l.value.as_str(), l.effective)) + .collect::>(), + vec![ + (LayerKind::Project, "Inherits", false), + (LayerKind::Environment, "On", true), + (LayerKind::Default, "Off", false), + ] + ); + let overridden = layers(Key::Agent, "Example Mac", true, Some(false), true); + assert!(overridden[0].effective && overridden[0].value == "Off"); + assert!(!overridden[1].effective); + let default = layers(Key::Agent, "Example Mac", false, None, false); + assert_eq!(default.len(), 2); + assert_eq!(default[0].value, "Inherits"); + assert!(default[1].effective); + let hub = layers(Key::Hub, "Example Mac", true, None, true); + assert!(hub.iter().all(|l| l.kind != LayerKind::Project)); + } + + #[test] + fn inheritance_summaries_follow_the_row() { + assert_eq!( + inheritance(Key::Agent, true, None, true, "Example Mac"), + (State::Inherited, "Inherited from Example Mac".to_owned()) + ); + assert_eq!( + inheritance(Key::Agent, true, Some(true), false, "x").0, + State::Overridden + ); + assert_eq!( + inheritance(Key::Hub, true, None, true, "x").0, + State::Environment + ); + assert_eq!( + inheritance(Key::Hub, false, None, false, "x").0, + State::Default + ); + assert!(marker_always_visible(State::Inherited)); + assert!(!marker_always_visible(State::Default)); + } + + #[test] + fn override_patch_replaces_the_whole_entry() { + let config = DeviceConfig::from_settings(&json!({ + "projectSettingsOverrides": {"p": {"defaultRuntimeMode": "full-access"}} + })); + assert_eq!( + override_patch(&config, "p", Key::Agent, Some(true)), + json!({"patch": {"projectSettingsOverrides": {"p": { + "defaultRuntimeMode": "full-access", "enableAgentDeviceAccess": true + }}}}) + ); + let only = DeviceConfig::from_settings(&json!({ + "projectSettingsOverrides": {"p": {"enableAgentDeviceAccess": true}} + })); + assert_eq!( + override_patch(&only, "p", Key::Agent, None), + json!({"patch": {"projectSettingsOverrides": {"p": null}}}) + ); + } + + #[test] + fn configure_payloads_match_the_switches() { + assert_eq!( + configure_payload(Key::Hub, true), + json!({"enabled": true, "onboardingCompleted": true}) + ); + assert_eq!( + configure_payload(Key::Hub, false), + json!({"enabled": false, "agentAccessEnabled": false}) + ); + assert_eq!( + configure_payload(Key::Agent, true), + json!({"agentAccessEnabled": true}) + ); + } + + fn state() -> DeviceState { + DeviceState::parse(&json!({ + "supportsEasHosts": true, + "supportsToolUpdate": true, + "hostStatus": "idle", + "hostStatuses": { + "a": {"status": "failed", "detail": "no route"}, + "b": {"status": "ready", "androidDiscoveryIncomplete": true}, + "c": {"status": "starting"} + }, + "hosts": [{ + "id": "local", "kind": "local", "label": "This machine", + "platforms": [{"platform": "ios", "available": true}, + {"platform": "android", "available": false, "reason": "No SDK"}], + "tools": { + "hub": {"requiredVersion": "1.2.0", "installedVersions": ["1.1.0", "1.10.0"], "runningVersion": null}, + "agent": {"requiredVersion": "2.0.0", "installedVersions": [], "runningVersion": null} + }, + "hubInstalled": true, "agentDeviceInstalled": false + }], + "easHosts": [{"id": "e", "kind": "eas", "label": "E", "platforms": []}] + })) + } + + #[test] + fn state_reports_tool_versions_like_the_web() { + let state = state(); + assert_eq!(state.hosts.len(), 2); + assert_eq!(state.tool_label(Tool::Hub), "v1.10.0"); + assert_eq!(state.tool_label(Tool::Agent), "Not installed"); + assert!(state.tool(Tool::Hub).unwrap().needs_update()); + assert_eq!( + DeviceState::default().tool_label(Tool::Hub), + "Version unknown" + ); + let running = ToolVersion { + required: "1.0.0".into(), + installed: vec!["1.0.0".into()], + running: Some("0.9.0".into()), + }; + assert_eq!(running.version().as_deref(), Some("0.9.0")); + } + + #[test] + fn busy_means_starting_with_nothing_ready() { + let mut state = state(); + assert!(!state.busy()); + state.host_statuses.remove("b"); + assert!(state.busy()); + assert!(!DeviceState::default().busy()); + } + + #[test] + fn host_status_maps_the_tones() { + let state = state(); + assert_eq!(host_status(Some(&state), "a").unwrap().tone, Tone::Error); + assert_eq!( + host_status(Some(&state), "b").unwrap().label, + "Android list may be incomplete" + ); + assert_eq!(host_status(Some(&state), "c").unwrap().label, "Starting"); + assert_eq!(host_status(Some(&state), "missing"), None); + assert_eq!(host_status(None, "a").unwrap().label, "Loading"); + } + + #[test] + fn rows_list_the_local_source_first_and_gate_by_support() { + let config = DeviceConfig::from_settings(&json!({ + "deviceHosts": [{"id": "a", "label": "Example Mini", "target": "user@example"}], + "easDeviceHosts": [{ + "kind": "eas", "id": "e", "label": "EAS Simulator", "accessToken": "", + "maxSessionMinutes": 30, "idleMinutes": 10, "maxConcurrentSessions": 1 + }], + "simlockDeviceHosts": [{ + "kind": "simlock", "id": "s", "label": "Pool", "gatewayUrl": "https://gateway.example.test:4700", + "maxLeases": 2, "maxLeaseMinutes": 60 + }], + })); + let state = state(); + let listed = rows(&config, Some(&state), 1, true); + assert_eq!( + listed.iter().map(|r| r.title.as_str()).collect::>(), + vec!["Local simulators", "Example Mini", "EAS Simulator"] + ); + assert_eq!( + listed[0].description, + "Simulators and emulators installed on this environment's machine." + ); + assert_eq!(listed[1].kind, Some("SSH")); + assert_eq!( + listed[2].description, + "Up to 1 session at once, each stopped after 30 min or 10 idle min" + ); + let mut with_simlock = config.clone(); + with_simlock.simlock_device_hosts_enabled = true; + let mut supported = state.clone(); + supported.supports_simlock_hosts = true; + let listed = rows(&with_simlock, Some(&supported), 2, true); + assert_eq!(listed.len(), 4); + assert_eq!( + listed[3].description, + "gateway.example.test:4700 \u{b7} Up to 2 devices at once, each released after 60 min" + ); + assert!( + listed[0] + .description + .contains("each environment's own machine") + ); + let off = rows(&config, Some(&state), 1, false); + assert!( + off.iter() + .all(|r| r.platforms.is_empty() && r.status.is_none()) + ); + } + + #[test] + fn add_menu_offers_what_the_server_supports() { + let none = add_menu(None, true); + assert_eq!(none, vec![(AddKind::Ssh, "Machine over SSH")]); + let mut state = state(); + assert_eq!(add_menu(Some(&state), true).len(), 2); + state.supports_simlock_hosts = true; + assert_eq!(add_menu(Some(&state), false).len(), 2); + assert_eq!( + add_menu(Some(&state), true).last(), + Some(&(AddKind::Simlock, "Simlock pool (alpha)")) + ); + } + + #[test] + fn ssh_hosts_add_replace_and_remove() { + let a = ssh("a", "user@one"); + let b = ssh("b", "user@two"); + let added = update_ssh_hosts(std::slice::from_ref(&a), &b, false, &b).unwrap(); + assert_eq!(added, vec![a.clone(), b.clone()]); + let renamed = SshHost { + label: "Renamed".into(), + ..b.clone() + }; + let replaced = update_ssh_hosts(&added, &renamed, false, &b).unwrap(); + assert_eq!(replaced[1].label, "Renamed"); + let removed = update_ssh_hosts(&replaced, &renamed, true, &renamed).unwrap(); + assert_eq!(removed, vec![a.clone()]); + let twins = vec![ + a.clone(), + SshHost { + id: "c".into(), + ..a.clone() + }, + ]; + assert!(update_ssh_hosts(&twins, &a, false, &ssh("x", "user@one")).is_err()); + let same_destination = update_ssh_hosts( + std::slice::from_ref(&a), + &SshHost { + id: "n".into(), + ..a.clone() + }, + false, + &SshHost { + id: "n".into(), + ..a.clone() + }, + ) + .unwrap(); + assert_eq!(same_destination.len(), 1); + assert_eq!(same_destination[0].id, "a"); + } + + #[test] + fn eas_and_simlock_hosts_keep_tokens_and_update_by_id() { + let mut saved = new_eas_host("e".into()); + saved.access_token = json!("redacted"); + let mut edited = saved.clone(); + edited.access_token = json!(""); + edited.label = "Renamed".into(); + let kept = update_eas_hosts(&[saved.clone()], &edited, false, true); + assert_eq!(kept[0].access_token, json!("redacted")); + assert_eq!(kept[0].label, "Renamed"); + let dropped = update_eas_hosts(&[saved.clone()], &edited, false, false); + assert_eq!(dropped[0].access_token, json!("")); + assert!(update_eas_hosts(&[saved.clone()], &saved, true, false).is_empty()); + let simlock = new_simlock_host("s".into()); + let list = update_simlock_hosts(&[], &simlock, false); + assert_eq!(list.len(), 1); + assert!(update_simlock_hosts(&list, &simlock, true).is_empty()); + } + + #[test] + fn drafts_validate_like_the_contract() { + let mut host = ssh("a", " user@example "); + host.label = " Mini ".into(); + host.identity_file = Some(" ".into()); + let parsed = parse_ssh_draft(&host).unwrap(); + assert_eq!(parsed.target, "user@example"); + assert_eq!(parsed.label, "Mini"); + assert_eq!(parsed.identity_file, None); + assert!(parse_ssh_draft(&ssh("a", "-oProxy")).is_none()); + assert!(parse_ssh_draft(&ssh("a", "two words")).is_none()); + assert!(parse_ssh_draft(&ssh("local", "user@example")).is_none()); + assert!( + parse_ssh_draft(&SshHost { + port: Some(0), + ..ssh("a", "x") + }) + .is_none() + ); + let blank = SshHost { + label: " ".into(), + ..ssh("a", "user@example") + }; + assert!(parse_ssh_draft(&blank).is_none()); + assert_eq!(ssh_test_host(&blank).unwrap().label, "user@example"); + assert!(valid_host_id(&new_host_id())); + assert_ne!(new_host_id(), new_host_id()); + } + + #[test] + fn limits_problems_name_the_cause() { + let mut eas = new_eas_host("e".into()); + assert_eq!(eas_problem(&eas), None); + eas.idle_minutes = 30; + assert_eq!( + eas_problem(&eas), + Some("Idle minutes must be smaller than session minutes.") + ); + eas.idle_minutes = 5; + eas.label = " ".into(); + assert_eq!( + eas_problem(&eas), + Some("Give the host a label and limits within the ranges shown.") + ); + let mut simlock = new_simlock_host("s".into()); + assert_eq!( + simlock_problem(&simlock), + Some("Enter the gateway's origin, such as https://gateway.example.ts.net:4700.") + ); + simlock.gateway_url = "https://gateway.example.test:4700/".into(); + assert_eq!(simlock_problem(&simlock), None); + simlock.max_leases = 40; + assert_eq!( + simlock_problem(&simlock), + Some("Keep the limits within the ranges shown.") + ); + simlock.label = "".into(); + assert_eq!(simlock_problem(&simlock), Some("Give the host a label.")); + assert!(!valid_gateway("https://host/path")); + assert!(!valid_gateway("ftp://host")); + } + + #[test] + fn sources_are_off_in_a_project_scope() { + assert!(sources_disabled(true, false, true)); + assert!(sources_disabled(false, true, true)); + assert!(sources_disabled(false, false, false)); + assert!(!sources_disabled(false, false, true)); + } + + #[test] + fn searchable_titles_match_the_web() { + assert_eq!( + DEVICE_ITEMS.iter().map(|i| i.title).collect::>(), + vec![ + "Device sources", + "Agents can use devices", + "Use devices", + "Local simulators" + ] + ); + assert!(DEVICE_ITEMS.iter().all(|i| i.section == Section::Devices)); + } + #[test] + fn setup_label_follows_the_host_status() { + let mut state = DeviceState::default(); + assert_eq!(state.setup_label(), "Updating\u{2026}"); + state.host_status = Some(HostStatus::Installing); + assert_eq!(state.setup_label(), "Installing\u{2026}"); + state.host_status = Some(HostStatus::Starting); + assert_eq!(state.setup_label(), "Starting\u{2026}"); + } + + #[test] + fn override_patches_merge_per_project() { + let config = DeviceConfig::default(); + let merged = merge_override_patches(vec![ + override_patch(&config, "p1", Key::Agent, Some(true)), + override_patch(&config, "p2", Key::Agent, None), + ]); + let entries = &merged["patch"]["projectSettingsOverrides"]; + assert_eq!(entries["p1"]["enableAgentDeviceAccess"], json!(true)); + assert!(entries["p2"].is_null()); + } +} diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 8b5c25c9471e..4cff0e65c30f 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -502,8 +502,18 @@ impl MainView { }; let data = crate::settings_labs::scope_data(&label, machine, &entries, &self.environments); let flags = self.providers.as_ref().map(|c| c.labs_flags()); - self.settings - .update(cx, |settings, cx| settings.set_labs_data(data, flags, cx)); + let devices = self.providers.as_ref().map(|c| { + ( + c.devices().clone(), + crate::settings_devices::project_ids(&entries), + ) + }); + self.settings.update(cx, |settings, cx| { + settings.set_labs_data(data, flags, cx); + if let Some((config, projects)) = devices { + settings.set_devices_data(config, projects, cx); + } + }); } pub fn set_status(&mut self, status: Status, cx: &mut Context) { @@ -574,6 +584,12 @@ impl MainView { return cx.notify(); } if let Status::Call { token, event } = status { + if token >= crate::settings_devices::TOKEN_BASE { + self.settings.update(cx, |settings, cx| { + settings.on_devices_call(token, event, cx) + }); + return; + } if token >= crate::settings_labs::TOKEN_BASE { self.settings .update(cx, |settings, cx| settings.on_labs_call(token, event, cx)); diff --git a/apps/desktop-gpui/src/ui/settings.rs b/apps/desktop-gpui/src/ui/settings.rs index 7fa5a99909b6..c49fd17562fa 100644 --- a/apps/desktop-gpui/src/ui/settings.rs +++ b/apps/desktop-gpui/src/ui/settings.rs @@ -2,8 +2,10 @@ //! navigation (`SettingsSidebarNav.tsx`) and the General tab (`GeneralSettingsPanel`). mod components; +mod devices_panel; mod diffs_panel; mod labs_panel; +mod scope_inheritance; mod scope_pickers; mod tokens; @@ -17,9 +19,11 @@ use components::{ HoverHandler, KeyHandler, Menu, PickHandler, SelectTrigger, close_button, disclosure, ghost_button, group, reset_button, row, section, select_menu, select_menu_checked, switch, }; +use devices_panel::DevicesPanel; use diffs_panel::DiffsPanel; use gpui::{ClickEvent, FocusHandle, KeyBinding, KeyDownEvent, ScrollHandle, Subscription}; use labs_panel::{LabsEvent, LabsPanel}; +use scope_pickers::ScopeBar; use std::rc::Rc; use tokens::Tokens; @@ -156,6 +160,8 @@ pub struct SettingsDialog { advanced_open: bool, diffs: Entity, labs: Entity, + devices: Entity, + scope: Entity, confirm_restore: bool, scroll: ScrollHandle, _search_events: Subscription, @@ -168,7 +174,9 @@ impl SettingsDialog { window: &mut Window, cx: &mut Context, ) -> Self { - let labs = cx.new(|cx| LabsPanel::new(actions, window, cx)); + let scope = cx.new(|cx| ScopeBar::new(window, cx)); + let devices = cx.new(|cx| DevicesPanel::new(actions.clone(), scope.clone(), cx)); + let labs = cx.new(|cx| LabsPanel::new(actions, scope.clone(), cx)); let labs_events = cx.subscribe_in(&labs, window, |this, _, event: &LabsEvent, _, cx| { let LabsEvent::OpenSection(section) = event; this.select_section(*section, cx); @@ -198,6 +206,8 @@ impl SettingsDialog { advanced_open: false, diffs: cx.new(DiffsPanel::new), labs, + devices, + scope, confirm_restore: false, scroll: ScrollHandle::new(), _search_events: events, @@ -214,6 +224,7 @@ impl SettingsDialog { self.settings = DeviceSettings::load(); self.diffs.update(cx, |panel, cx| panel.reload(cx)); self.labs.update(cx, |panel, cx| panel.reload(cx)); + self.devices.update(cx, |panel, cx| panel.reload(cx)); self.menu = None; self.advanced_open = false; self.confirm_restore = false; @@ -240,8 +251,10 @@ impl SettingsDialog { flags: Option, cx: &mut Context, ) { - self.labs - .update(cx, |panel, cx| panel.set_data(data, flags, cx)); + self.scope.update(cx, |bar, cx| bar.set_data(data, cx)); + if let Some(flags) = flags { + self.labs.update(cx, |panel, cx| panel.set_flags(flags, cx)); + } } pub fn on_labs_call( @@ -254,6 +267,26 @@ impl SettingsDialog { .update(cx, |panel, cx| panel.on_call(token, event, cx)); } + pub fn set_devices_data( + &mut self, + config: crate::settings_devices::DeviceConfig, + projects: crate::settings_devices::ProjectIds, + cx: &mut Context, + ) { + self.devices + .update(cx, |panel, cx| panel.set_data(config, projects, cx)); + } + + pub fn on_devices_call( + &mut self, + token: u64, + event: crate::status::CallEvent, + cx: &mut Context, + ) { + self.devices + .update(cx, |panel, cx| panel.on_call(token, event, cx)); + } + fn query(&self, cx: &App) -> String { self.search.read(cx).value().to_string() } @@ -311,12 +344,21 @@ impl SettingsDialog { } fn escape(&mut self, window: &mut Window, cx: &mut Context) { + if self.scope.update(cx, |bar, cx| bar.close_menu(cx)) { + return; + } if self .labs .update(cx, |panel, cx| panel.close_menu(window, cx)) { return; } + if self + .devices + .update(cx, |panel, cx| panel.close_menu(window, cx)) + { + return; + } if self .diffs .update(cx, |panel, cx| panel.close_menu(window, cx)) @@ -445,6 +487,9 @@ impl Render for SettingsDialog { let header = self.header(cx); let body = self.body(width - NAV_WIDTH, window, cx); let confirm = self.restore_dialog(cx); + let devices = self + .devices + .update(cx, |panel, cx| panel.overlay(window, cx)); div() .absolute() .inset_0() @@ -503,6 +548,7 @@ impl Render for SettingsDialog { ), ) .children(confirm) + .children(devices) .into_any_element() } } @@ -984,9 +1030,15 @@ impl SettingsDialog { } else if self.section == Section::Labs { self.labs.update(cx, |panel, cx| panel.set_wide(wide, cx)); vec![self.labs.clone().into_any_element()] + } else if self.section == Section::Devices { + self.devices + .update(cx, |panel, cx| panel.set_wide(wide, cx)); + vec![self.devices.clone().into_any_element()] } else { Vec::new() }; + // The web hides the scope sentence on device-only tabs; the tabs that apply a scope list here. + let scoped = matches!(self.section, Section::Labs | Section::Devices); div() .w_full() .flex() @@ -999,6 +1051,7 @@ impl SettingsDialog { .px_6() .pt_6() .pb_12() + .children(scoped.then(|| self.scope.clone().into_any_element())) .children(panel), ) .into_any_element() diff --git a/apps/desktop-gpui/src/ui/settings/devices_panel.rs b/apps/desktop-gpui/src/ui/settings/devices_panel.rs new file mode 100644 index 000000000000..14a408b6b4b8 --- /dev/null +++ b/apps/desktop-gpui/src/ui/settings/devices_panel.rs @@ -0,0 +1,1364 @@ +//! The Devices tab: `DeviceIntegrationSettings` (IntegrationsSettings.tsx:804-1030) and +//! `DeviceSourcesSettings` (DeviceSourcesSettings.tsx) with their host editors. The panel owns +//! its popovers, editors and requests, so the other tabs stay untouched. Server-settings writes +//! follow the Labs tab: optimistic, put back when the call fails. + +mod editors; +mod widgets; + +use super::components::{Toggle, group, reset_button, section, switch}; +use super::scope_inheritance::{Marker, PopTrigger, marker_row, popup_shell}; +use super::scope_pickers::ScopeBar; +use super::tokens::Tokens; +use super::*; +use crate::connection::{Action, ActionSender, Call}; +use crate::settings_devices::{ + self as devices, AddKind, DeviceConfig, DeviceState, Key, Row, Source, StatusAction, Tone, Tool, +}; +use crate::settings_labs::{Scope, resolve}; +use crate::status::CallEvent; +use editors::Editor; +use gpui::{ElementId, Subscription}; +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::rc::Rc; +use widgets::{Variant, button, icon_button, menu_item, spinner, warning}; + +/// A switch write in flight. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Busy { + Key(Key), +} + +/// A version action in flight (`pendingAction`). +#[derive(Clone, Copy, PartialEq, Eq)] +enum Versions { + Update(Tool), + Inspect, +} + +/// What a request in flight is for. +enum Pending { + Configure { + previous: DeviceConfig, + }, + Override { + previous: DeviceConfig, + }, + List { + versions: Option, + }, + Hosts { + previous: DeviceConfig, + title: &'static str, + }, + Test { + seq: u64, + }, + RowTest { + label: String, + }, + Account { + seq: u64, + }, +} + +/// A failure or a test result, shown under the sources card in place of the web's toast. +struct Notice { + title: String, + description: String, + error: bool, +} + +pub struct DevicesPanel { + actions: ActionSender, + scope: Entity, + wide: bool, + config: DeviceConfig, + projects: devices::ProjectIds, + state: Option, + busy: Option, + versions: Option, + update_error: Option<(Tool, String)>, + pending: HashMap, + next_token: u64, + notice: Option, + add_menu: bool, + row_menu: Option, + editor: Option, + _subscriptions: Vec, +} + +/// What the scope bar says about the one attached environment. +struct Ctx { + in_project: bool, + members: Vec, + label: String, + machine: crate::projects::MachineKind, + connected: bool, + hub: Result<(), String>, + agent: Result<(), String>, +} + +fn is_state(value: &Value) -> bool { + value.get("hosts").is_some() +} + +impl DevicesPanel { + pub fn new(actions: ActionSender, scope: Entity, cx: &mut Context) -> Self { + let observed = cx.observe(&scope, |_, _, cx| cx.notify()); + Self { + actions, + scope, + wide: true, + config: DeviceConfig::default(), + projects: devices::ProjectIds::new(), + state: None, + busy: None, + versions: None, + update_error: None, + pending: HashMap::new(), + next_token: devices::TOKEN_BASE, + notice: None, + add_menu: false, + row_menu: None, + editor: None, + _subscriptions: vec![observed], + } + } + + pub fn set_wide(&mut self, wide: bool, cx: &mut Context) { + if self.wide != wide { + self.wide = wide; + cx.notify(); + } + } + + /// The attached environment's device settings and the projects that hold the overrides. + pub fn set_data( + &mut self, + config: DeviceConfig, + projects: devices::ProjectIds, + cx: &mut Context, + ) { + let mut changed = self.projects != projects; + self.projects = projects; + if self.pending.is_empty() && self.config != config { + self.config = config; + changed = true; + } + if changed { + cx.notify(); + } + } + + /// Closes menus and the dialog as the settings page opens, and asks for the device state. + pub fn reload(&mut self, cx: &mut Context) { + self.add_menu = false; + self.row_menu = None; + self.editor = None; + self.notice = None; + self.update_error = None; + self.list(json!({}), None, cx); + cx.notify(); + } + + /// Closes the innermost open menu or the dialog; returns whether there was one. + pub fn close_menu(&mut self, _: &mut Window, cx: &mut Context) -> bool { + let closed = if self.row_menu.take().is_some() || std::mem::take(&mut self.add_menu) { + true + } else if let Some(editor) = &mut self.editor { + if std::mem::take(&mut editor.app_menu) { + true + } else if editor.saving { + false + } else { + self.editor = None; + true + } + } else { + false + }; + if closed { + cx.notify(); + } + closed + } + + fn send(&mut self, call: Call, payload: Value, pending: Pending) -> bool { + let token = self.next_token; + self.next_token += 1; + let action = Action::Call { + token, + call, + payload, + }; + if self.actions.try_send(action).is_err() { + return false; + } + self.pending.insert(token, pending); + true + } + + fn list(&mut self, payload: Value, versions: Option, cx: &mut Context) { + if self.send(Call::DeviceList, payload, Pending::List { versions }) { + self.versions = versions; + } + cx.notify(); + } + + fn ctx(&self, cx: &App) -> Ctx { + let scope = self.scope.read(cx); + let (search, data) = (scope.search(), scope.data()); + let environment = data + .environments + .iter() + .find(|e| e.connected && e.writable) + .or(data.environments.first()); + Ctx { + in_project: matches!(resolve(search, data), Scope::Project(_)), + members: search + .project + .as_ref() + .and_then(|key| self.projects.get(key)) + .cloned() + .unwrap_or_default(), + label: environment.map_or_else(|| "Environment".to_owned(), |e| e.label.clone()), + machine: environment.map_or(crate::projects::MachineKind::Desktop, |e| e.machine), + connected: environment.is_some_and(|e| e.connected && e.writable), + hub: devices::reach(Key::Hub, search, data), + agent: devices::reach(Key::Agent, search, data), + } + } + + fn project_override(&self, key: Key, ctx: &Ctx) -> Option { + ctx.members + .iter() + .find_map(|id| self.config.project_override(key, id)) + } + + fn value(&self, key: Key, ctx: &Ctx) -> bool { + if ctx.in_project && key.project_scoped() { + self.project_override(key, ctx) + .unwrap_or_else(|| self.config.environment_value(key)) + } else { + self.config.environment_value(key) + } + } + + fn loaded(&self) -> bool { + self.state.is_some() + } + + fn hub_busy(&self) -> bool { + self.busy.is_some() || self.state.as_ref().is_some_and(DeviceState::busy) + } + + fn hosts_busy(&self) -> bool { + self.pending + .values() + .any(|p| matches!(p, Pending::Hosts { .. })) + } + + /// Whether the switch ignores clicks (IntegrationsSettings.tsx:950-1017). + fn gated(&self, key: Key, ctx: &Ctx) -> bool { + match key { + Key::Hub => ctx.hub.is_err() || !self.loaded() || !ctx.connected || self.hub_busy(), + Key::Agent => { + ctx.agent.is_err() + || !ctx.connected + || (!ctx.in_project + && (!self.loaded() + || !self.config.enable_device_support + || self.hub_busy())) + } + } + } + + fn toggle(&mut self, key: Key, cx: &mut Context) { + let ctx = self.ctx(cx); + if self.gated(key, &ctx) { + return; + } + let next = !self.value(key, &ctx); + self.write(key, Some(next), &ctx, cx); + } + + fn reset(&mut self, key: Key, cx: &mut Context) { + let ctx = self.ctx(cx); + if self.gated(key, &ctx) { + return; + } + let value = if ctx.in_project && key.project_scoped() { + None + } else { + Some(false) + }; + self.write(key, value, &ctx, cx); + } + + /// A project scope writes the override of every project of the group (`None` removes it); + /// anywhere else the environment's value is written through `device.configure`. + fn write(&mut self, key: Key, value: Option, ctx: &Ctx, cx: &mut Context) { + let previous = self.config.clone(); + if ctx.in_project && key.project_scoped() { + let patch = devices::merge_override_patches( + ctx.members + .iter() + .map(|id| devices::override_patch(&self.config, id, key, value)) + .collect(), + ); + for id in &ctx.members { + let entry = self + .config + .project_settings_overrides + .entry(id.clone()) + .or_default(); + match value { + Some(value) => { + entry.insert(key.wire().to_owned(), json!(value)); + } + None => { + entry.remove(key.wire()); + } + } + } + if !self.send( + Call::UpdateSettings, + patch, + Pending::Override { + previous: previous.clone(), + }, + ) { + self.config = previous; + } + } else { + let value = value.unwrap_or(false); + match key { + Key::Hub => { + self.config.enable_device_support = value; + if !value { + self.config.enable_agent_device_access = false; + } + } + Key::Agent => self.config.enable_agent_device_access = value, + } + if self.send( + Call::DeviceConfigure, + devices::configure_payload(key, value), + Pending::Configure { + previous: previous.clone(), + }, + ) { + self.busy = Some(Busy::Key(key)); + } else { + self.config = previous; + } + } + cx.notify(); + } + + fn update_tool(&mut self, tool: Tool, cx: &mut Context) { + self.update_error = None; + self.list( + json!({"updateTool": tool.wire()}), + Some(Versions::Update(tool)), + cx, + ); + } + + fn check_versions(&mut self, cx: &mut Context) { + self.update_error = None; + self.list(json!({"inspectOnly": true}), Some(Versions::Inspect), cx); + } + + fn notify_failure(&mut self, title: impl Into, description: impl Into) { + self.notice = Some(Notice { + title: title.into(), + description: description.into(), + error: true, + }); + } + + /// The outcome of a request. + pub fn on_call(&mut self, token: u64, event: CallEvent, cx: &mut Context) { + if matches!(event, CallEvent::Progress(_)) { + return; + } + let Some(pending) = self.pending.remove(&token) else { + return; + }; + let label = self.ctx(cx).label; + match (pending, event) { + (Pending::Configure { previous }, event) => { + self.busy = None; + match event { + CallEvent::Done(value) if is_state(&value) => { + self.state = Some(DeviceState::parse(&value)); + } + CallEvent::Failed(_) => { + self.config = previous; + self.notify_failure( + "Device settings not saved on all environments", + format!("Could not update {label}."), + ); + } + _ => {} + } + } + (Pending::Override { previous }, CallEvent::Failed(_)) => { + self.config = previous; + self.notify_failure( + "Device settings not saved on all environments", + format!("Could not update {label}."), + ); + } + (Pending::List { versions }, event) => { + if self.versions == versions { + self.versions = None; + } + match event { + CallEvent::Done(value) => { + self.state = Some(DeviceState::parse(&value)); + self.update_error = None; + } + CallEvent::Failed(message) => match versions { + Some(Versions::Update(tool)) => self.update_error = Some((tool, message)), + _ => self.notify_failure("Could not load devices", message), + }, + CallEvent::Progress(_) => {} + } + } + (Pending::Hosts { previous, title }, event) => match event { + CallEvent::Done(_) => { + self.editor = None; + self.list(json!({}), None, cx); + } + CallEvent::Failed(message) => { + self.config = previous; + if let Some(editor) = &mut self.editor { + editor.saving = false; + editor.error = Some(message.clone()); + } + self.notify_failure(title, format!("{label}: {message}")); + } + CallEvent::Progress(_) => {} + }, + (Pending::Test { seq }, event) => { + if let Some(editor) = self.editor.as_mut().filter(|e| e.seq == seq) { + editor.finish_test(event); + } + } + (Pending::RowTest { label: host }, event) => { + self.notice = Some(match event { + CallEvent::Failed(message) => Notice { + title: format!("{host}: 1 of 1 environments failed"), + description: format!("{label}: {message}"), + error: true, + }, + _ => Notice { + title: format!("{host}: connection checks passed"), + description: + "Connected or already available locally on each selected environment." + .to_owned(), + error: false, + }, + }); + } + (Pending::Account { seq }, event) => { + if let Some(editor) = self.editor.as_mut().filter(|e| e.seq == seq) { + editor.finish_account(event); + } + } + _ => {} + } + cx.notify(); + } + + fn open_editor( + &mut self, + kind: AddKind, + existing: Option<&Source>, + window: &mut Window, + cx: &mut Context, + ) { + self.add_menu = false; + self.row_menu = None; + let editor = Editor::open(kind, existing, &self.config, window, cx); + let account = editor.wants_account(); + self.editor = Some(editor); + if account { + self.check_account(cx); + } + cx.notify(); + } + + fn save_editor(&mut self, cx: &mut Context) { + let Some(editor) = &mut self.editor else { + return; + }; + let Some(save) = editor.save(&self.config, cx) else { + return; + }; + match save { + Err(message) => editor.error = Some(message), + Ok(save) => { + editor.saving = true; + editor.error = None; + let previous = self.config.clone(); + save.apply(&mut self.config); + let payload = save.patch(); + if !self.send( + Call::UpdateSettings, + payload, + Pending::Hosts { + previous: previous.clone(), + title: save.title, + }, + ) { + self.config = previous; + if let Some(editor) = &mut self.editor { + editor.saving = false; + } + } + } + } + cx.notify(); + } + + fn remove(&mut self, row: &Row, cx: &mut Context) { + self.row_menu = None; + let Some(save) = editors::removal(&row.source, &self.config) else { + return; + }; + let previous = self.config.clone(); + save.apply(&mut self.config); + let patch = save.patch(); + if !self.send( + Call::UpdateSettings, + patch, + Pending::Hosts { + previous: previous.clone(), + title: save.title, + }, + ) { + self.config = previous; + } + cx.notify(); + } + + fn test_row(&mut self, row: &Row, cx: &mut Context) { + self.row_menu = None; + if let Source::Ssh(host) = &row.source { + self.send( + Call::DeviceTestHost, + devices::test_payload(host), + Pending::RowTest { + label: host.label.clone(), + }, + ); + } + cx.notify(); + } + + pub(super) fn test_connection(&mut self, cx: &mut Context) { + let Some(editor) = &mut self.editor else { + return; + }; + let Some(payload) = editor.start_test(cx) else { + return; + }; + let seq = editor.seq; + self.send(Call::DeviceTestHost, payload, Pending::Test { seq }); + cx.notify(); + } + + pub(super) fn check_account(&mut self, cx: &mut Context) { + let Some(editor) = &mut self.editor else { + return; + }; + let payload = editor.start_account(cx); + let seq = editor.seq; + self.send(Call::DeviceEasAccount, payload, Pending::Account { seq }); + cx.notify(); + } + + fn dismiss_notice(&mut self, cx: &mut Context) { + self.notice = None; + cx.notify(); + } + + /// The switch, inert under a tooltip when the scope or a precondition forbids a write. + fn switch_control( + &self, + id: &'static str, + key: Key, + ctx: &Ctx, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let view = cx.entity(); + let checked = self.value(key, ctx); + let why = match key { + Key::Hub => ctx.hub.clone().err(), + Key::Agent => ctx.agent.clone().err(), + }; + let toggle: Toggle = if self.gated(key, ctx) { + Rc::new(|_, _| {}) + } else { + Rc::new(move |_, cx| view.update(cx, |this, cx| this.toggle(key, cx))) + }; + let switch = switch(id, checked, toggle, window, cx); + if !self.gated(key, ctx) { + return switch; + } + div() + .id((id, 1usize)) + .opacity(0.5) + .when_some(why, |inert, why| inert.tooltip(tooltip(why))) + .child(switch) + .into_any_element() + } + + /// The spinner and label beside a switch whose write is in flight. + fn pending_label(&self, key: Key, cx: &App) -> Option { + (self.busy == Some(Busy::Key(key))).then(|| { + let muted = Tokens::of(cx).muted_foreground; + h_flex() + .items_center() + .gap_1() + .text_xs() + .text_color(muted) + .child(spinner(12., muted)) + .child( + self.state + .as_ref() + .map_or("Updating\u{2026}", DeviceState::setup_label), + ) + .into_any_element() + }) + } + + fn reset_control( + &self, + id: &'static str, + key: Key, + ctx: &Ctx, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let dirty = if ctx.in_project && key.project_scoped() { + self.project_override(key, ctx).is_some() + } else if ctx.in_project { + false + } else { + self.config.environment_value(key) + }; + if !dirty || self.gated(key, ctx) { + return None; + } + let view = cx.entity(); + Some(reset_button( + id, + move |_, _, cx| view.update(cx, |this, cx| this.reset(key, cx)), + window, + cx, + )) + } + + fn marker(&self, id: &'static str, key: Key, ctx: &Ctx) -> Marker { + let project = self.project_override(key, ctx); + let environment = self.config.environment_value(key); + let (state, summary) = + devices::inheritance(key, ctx.in_project, project, environment, &ctx.label); + Marker { + id, + state, + summary, + layers: devices::layers(key, &ctx.label, ctx.in_project, project, environment), + machine: ctx.machine, + environment: ctx.label.clone(), + } + } + + /// `DeviceToolVersions`: the status link and its popover (DeviceToolVersions.tsx). + fn version_link( + &self, + id: &'static str, + tool: Tool, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let theme = Tokens::of(cx); + let (muted, foreground) = (theme.muted_foreground, theme.foreground); + let key = ElementId::from(id); + let open = window.use_keyed_state(ElementId::from((key.clone(), "open")), cx, |_, _| false); + let opened = *open.read(cx); + let toggled = open.clone(); + let label = self + .state + .as_ref() + .map_or_else(|| "Version unknown".to_owned(), |s| s.tool_label(tool)); + let trigger = div() + .id(key) + .font_weight(FontWeight::MEDIUM) + .text_color(muted) + .cursor_pointer() + .hover(move |style| style.text_color(foreground)) + .child(label); + let snapshot = VersionsView { + tool, + version: self.state.as_ref().and_then(|s| s.tool(tool).cloned()), + known: self + .state + .as_ref() + .is_some_and(|s| s.local().is_some_and(|h| h.tools.is_some())), + can_update: self.state.as_ref().is_some_and(|s| s.supports_tool_update), + can_inspect: self + .state + .as_ref() + .is_some_and(|s| s.supports_tool_inspection), + pending: self.versions, + error: self + .update_error + .as_ref() + .filter(|(failed, _)| *failed == tool) + .map(|(_, message)| message.clone()), + enabled: self.ctx(cx).connected && self.versions.is_none() && !self.hub_busy(), + }; + let view = cx.entity(); + Popover::new(ElementId::from((id, 2usize))) + .anchor(Anchor::TopRight) + .offset(px(4.)) + .open(opened) + .p_0() + .appearance(false) + .on_open_change(move |next, _, cx| toggled.update(cx, |open, _| *open = *next)) + .trigger(PopTrigger::new(trigger)) + .content(move |_, _, cx| popup_shell(cx).child(versions_body(&snapshot, &view, cx))) + .into_any_element() + } + + fn devices_card(&self, ctx: &Ctx, window: &mut Window, cx: &mut Context) -> gpui::Div { + let wide = self.wide; + let hub_status = self.version_link("settings-devices-hub-version", Tool::Hub, window, cx); + let agent_status = + self.version_link("settings-devices-agent-version", Tool::Agent, window, cx); + let hub_switch = h_flex() + .items_center() + .gap_2() + .children(self.pending_label(Key::Hub, cx)) + .child(self.switch_control("settings-devices-hub", Key::Hub, ctx, window, cx)) + .into_any_element(); + let agent_switch = h_flex() + .items_center() + .gap_2() + .children(self.pending_label(Key::Agent, cx)) + .child(self.switch_control("settings-devices-agent", Key::Agent, ctx, window, cx)) + .into_any_element(); + let hub = marker_row( + "settings-devices-hub-row", + "Use devices", + h_flex() + .flex_wrap() + .items_center() + .gap(px(4.)) + .child("Open simulators and emulators in the Device panel. Hub:") + .child(hub_status), + Some(self.marker("settings-devices-hub-marker", Key::Hub, ctx)), + self.reset_control("settings-devices-hub-reset", Key::Hub, ctx, window, cx), + hub_switch, + wide, + window, + cx, + ); + let agent = marker_row( + "settings-devices-agent-row", + "Agents can use devices", + h_flex() + .flex_wrap() + .items_center() + .gap(px(4.)) + .child("New agent sessions can open and drive these devices. agent-device:") + .child(agent_status), + Some(self.marker("settings-devices-agent-marker", Key::Agent, ctx)), + self.reset_control("settings-devices-agent-reset", Key::Agent, ctx, window, cx), + agent_switch, + wide, + window, + cx, + ); + group(vec![hub.into_any_element(), agent.into_any_element()], cx) + } + + /// The Add source button and its menu (DeviceSourcesSettings.tsx:521-557). + fn add_source(&self, disabled: bool, cx: &mut Context) -> AnyElement { + let enabled = !disabled && self.editor.is_none(); + let theme = Tokens::of(cx); + let foreground = theme.foreground; + let trigger = button("settings-device-add", Variant::Outline, enabled, cx) + .child(Icon::new(Icons::Plus).size(px(14.))) + .child("Add source") + .child( + div() + .opacity(0.7) + .text_color(foreground) + .child(Icon::new(Icons::ChevronDown).size(px(14.))), + ); + if !enabled { + return trigger.into_any_element(); + } + let items = devices::add_menu( + self.state.as_ref(), + self.config.simlock_device_hosts_enabled, + ); + let view = cx.entity(); + let opened = view.clone(); + Popover::new("settings-device-add-menu") + .anchor(Anchor::TopRight) + .offset(px(4.)) + .open(self.add_menu) + .p_0() + .appearance(false) + .on_open_change(move |next, _, cx| { + opened.update(cx, |this, cx| { + this.add_menu = *next; + cx.notify(); + }) + }) + .trigger(PopTrigger::new(trigger)) + .content(move |_, _, cx| { + popup_shell(cx) + .min_w(px(192.)) + .p_1() + .children(items.iter().enumerate().map(|(index, (kind, label))| { + let (view, kind) = (view.clone(), *kind); + menu_item(("settings-device-add-item", index), *label, false, cx).on_click( + move |_, window, cx| { + view.update(cx, |this, cx| this.open_editor(kind, None, window, cx)) + }, + ) + })) + }) + .into_any_element() + } + + fn platform_icon( + &self, + index: usize, + row_index: usize, + availability: &devices::Availability, + cx: &App, + ) -> AnyElement { + let theme = Tokens::of(cx); + let (name, icon) = match availability.platform { + devices::Platform::Ios => ("iOS", Icons::Apple), + devices::Platform::Android => ("Android", Icons::Bot), + }; + let text = if availability.available { + format!("{name} available") + } else { + format!( + "{name} not available. {}", + availability + .reason + .as_deref() + .unwrap_or("Not set up on this machine.") + ) + }; + div() + .id(("settings-device-platform", row_index * 2 + index)) + .size(px(24.)) + .flex() + .flex_shrink_0() + .items_center() + .justify_center() + .rounded(px(6.)) + .text_color(if availability.available { + theme.foreground + } else { + theme.muted_foreground.opacity(0.45) + }) + .tooltip(tooltip(text)) + .child(Icon::new(icon).size(px(16.))) + .into_any_element() + } + + fn row_actions( + &self, + index: usize, + row: &Row, + locked: bool, + cx: &mut Context, + ) -> Vec { + let view = cx.entity(); + let mut actions = Vec::new(); + let status_action = row.status.as_ref().and_then(|s| s.action.clone()); + if let Some(action) = status_action { + let (label, payload) = match action { + StatusAction::Refresh => ("Refresh", json!({})), + StatusAction::Retry(id) => ("Retry", json!({"retryHostId": id})), + }; + let enabled = self.versions.is_none(); + let view = view.clone(); + actions.push( + button( + ("settings-device-status-action", index), + Variant::Outline, + enabled, + cx, + ) + .child(label) + .when(enabled, |b| { + b.on_click(move |_, _, cx| { + view.update(cx, |this, cx| this.list(payload.clone(), None, cx)) + }) + }) + .into_any_element(), + ); + } + if matches!(row.source, Source::Local) { + return actions; + } + let enabled = !locked; + let existing = row.source.clone(); + let edit = { + let view = view.clone(); + button( + ("settings-device-edit", index), + Variant::Outline, + enabled, + cx, + ) + .child("Edit") + .when(enabled, |b| { + b.on_click(move |_, window, cx| { + let kind = match &existing { + Source::Eas(_) => AddKind::Eas, + Source::Simlock(_) => AddKind::Simlock, + _ => AddKind::Ssh, + }; + let existing = existing.clone(); + view.update(cx, |this, cx| { + this.open_editor(kind, Some(&existing), window, cx) + }) + }) + }) + .into_any_element() + }; + actions.push(edit); + let more = icon_button( + ("settings-device-more", index), + Variant::GhostMuted, + enabled, + cx, + ) + .child( + Icon::new(Icons::Ellipsis) + .size(px(16.)) + .rotate(gpui::radians(std::f32::consts::FRAC_PI_2)), + ); + if !enabled { + actions.push(more.into_any_element()); + return actions; + } + let key = row.key.clone(); + let opened = view.clone(); + let row = row.clone(); + let content_view = view; + actions.push( + Popover::new(("settings-device-more-menu", index)) + .anchor(Anchor::TopRight) + .offset(px(4.)) + .open(self.row_menu.as_deref() == Some(key.as_str())) + .p_0() + .appearance(false) + .on_open_change(move |next, _, cx| { + let key = key.clone(); + opened.update(cx, |this, cx| { + this.row_menu = next.then_some(key); + cx.notify(); + }) + }) + .trigger(PopTrigger::new(more)) + .content(move |_, _, cx| { + let mut menu = popup_shell(cx).min_w(px(160.)).p_1(); + if matches!(row.source, Source::Ssh(_)) { + let (view, row) = (content_view.clone(), row.clone()); + menu = menu.child( + menu_item( + ("settings-device-test", index), + "Test connection", + false, + cx, + ) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| this.test_row(&row, cx)) + }), + ); + } + let (view, row) = (content_view.clone(), row.clone()); + menu.child( + menu_item(("settings-device-remove", index), "Remove", true, cx).on_click( + move |_, _, cx| view.update(cx, |this, cx| this.remove(&row, cx)), + ), + ) + }) + .into_any_element(), + ); + actions + } + + fn source_row( + &self, + index: usize, + row: &Row, + locked: bool, + cx: &mut Context, + ) -> AnyElement { + let theme = Tokens::of(cx); + let warn = warning(&theme); + let (foreground, muted, destructive, border) = ( + theme.foreground, + theme.muted_foreground, + theme.destructive, + theme.border, + ); + let _ = foreground; + let badge = row.kind.map(|kind| { + div() + .flex_shrink_0() + .rounded(px(4.)) + .border_1() + .border_color(border) + .px(px(4.)) + .text_size(px(10.)) + .line_height(px(14.)) + .text_color(muted) + .child(kind.to_uppercase()) + }); + let status = row.status.as_ref().map(|status| { + let color = match status.tone { + Tone::Pending => muted, + Tone::Warning => warn, + Tone::Error => destructive, + }; + let text = match &status.detail { + Some(detail) => format!("{}: {detail}", status.label), + None => status.label.clone(), + }; + h_flex() + .items_center() + .gap_1() + .text_xs() + .text_color(color) + .when(status.tone == Tone::Pending, |line| { + line.child(spinner(12., color)) + }) + .child(div().min_w_0().truncate().child(text)) + }); + let icons: Vec = row + .platforms + .iter() + .enumerate() + .map(|(i, availability)| self.platform_icon(i, index, availability, cx)) + .collect(); + h_flex() + .items_center() + .gap_3() + .px_4() + .py_3() + .child( + v_flex() + .min_w_0() + .flex_1() + .gap(px(2.)) + .child( + h_flex() + .min_w_0() + .items_center() + .gap_2() + .child( + div() + .min_w_0() + .truncate() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .child(row.title.clone()), + ) + .children(badge), + ) + .child( + div() + .truncate() + .text_xs() + .text_color(muted) + .child(row.description.clone()), + ) + .children(status), + ) + .child(h_flex().items_center().gap_1().children(icons)) + .child( + h_flex() + .flex_shrink_0() + .items_center() + .gap_2() + .children(self.row_actions(index, row, locked, cx)), + ) + .into_any_element() + } + + fn notice_row(&self, cx: &mut Context) -> Option { + let notice = self.notice.as_ref()?; + let theme = Tokens::of(cx); + let view = cx.entity(); + let tone = if notice.error { + theme.destructive + } else { + theme.foreground + }; + Some( + h_flex() + .items_start() + .gap_3() + .rounded(px(8.)) + .border_1() + .border_color(if notice.error { + theme.destructive.opacity(0.5) + } else { + theme.border + }) + .px_3() + .py_2() + .child( + v_flex() + .min_w_0() + .flex_1() + .gap(px(2.)) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(tone) + .child(notice.title.clone()), + ) + .child( + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(notice.description.clone()), + ), + ) + .child( + icon_button( + "settings-device-notice-close", + Variant::GhostMuted, + true, + cx, + ) + .on_click(move |_, _, cx| view.update(cx, |this, cx| this.dismiss_notice(cx))) + .child(Icon::new(Icons::X).size(px(14.))), + ) + .into_any_element(), + ) + } + + fn sources_section(&self, ctx: &Ctx, cx: &mut Context) -> gpui::Div { + let active = self.config.enable_device_support; + let rows = devices::rows(&self.config, self.state.as_ref(), 1, active); + let locked = devices::sources_disabled(ctx.in_project, self.hosts_busy(), ctx.connected); + let cards: Vec = rows + .iter() + .enumerate() + .map(|(index, row)| self.source_row(index, row, locked, cx)) + .collect(); + let header = h_flex() + .min_h(px(28.)) + .items_center() + .justify_between() + .px_4() + .child( + div() + .text_base() + .font_weight(FontWeight::SEMIBOLD) + .child("Device sources"), + ) + .child(self.add_source(locked, cx)); + v_flex() + .gap(px(10.)) + .child(header) + .child(group(cards, cx)) + .children(self.notice_row(cx)) + } + + /// The host editor over a backdrop, built by the settings dialog next to its own overlays. + pub fn overlay(&mut self, window: &mut Window, cx: &mut Context) -> Option { + let label = self.ctx(cx).label; + let view = cx.entity(); + let editor = self.editor.as_ref()?; + Some(editor.view(&label, &view, window, cx)) + } +} + +/// What the version popover shows, taken when the panel renders. +struct VersionsView { + tool: Tool, + version: Option, + known: bool, + can_update: bool, + can_inspect: bool, + pending: Option, + error: Option, + enabled: bool, +} + +fn versions_body(snapshot: &VersionsView, view: &Entity, cx: &App) -> gpui::Div { + let theme = Tokens::of(cx); + let mono = cx.theme().mono_font_family.clone(); + let tool = snapshot.tool; + let mut body = v_flex().w(px(320.)).max_w_full().p_4().child( + div() + .text_sm() + .line_height(px(14.)) + .font_weight(FontWeight::SEMIBOLD) + .child(tool.name()), + ); + match (&snapshot.version, snapshot.known) { + (Some(version), true) => { + let value = |text: String| { + div() + .text_xs() + .font_family(mono.clone()) + .text_right() + .child(text) + }; + let label = |text: &'static str| { + div() + .text_xs() + .text_color(theme.muted_foreground) + .child(text) + }; + let installed = if version.installed.is_empty() { + "None".to_owned() + } else { + version + .installed + .iter() + .map(|v| format!("v{v}")) + .collect::>() + .join(", ") + }; + let running = version + .running + .as_ref() + .map_or_else(|| "Not running".to_owned(), |v| format!("v{v}")); + body = body.child( + div() + .mt_4() + .grid() + .grid_cols(2) + .gap_x_6() + .gap_y_1() + .child(label("Running")) + .child(value(running)) + .child(label("Required")) + .child(value(format!("v{}", version.required))) + .child(label("Installed")) + .child(value(installed)), + ); + } + _ => { + body = body.child( + div() + .mt_3() + .text_xs() + .text_color(theme.muted_foreground) + .child("Versions have not been checked."), + ); + } + } + body = body.child( + div() + .mt_4() + .pt_3() + .border_t_1() + .border_color(theme.border.opacity(0.5)) + .text_xs() + .text_color(theme.muted_foreground) + .child("Tools update automatically on this host when needed."), + ); + if let Some(error) = &snapshot.error { + body = body.child( + div() + .mt_2() + .text_xs() + .text_color(theme.destructive) + .child(error.clone()), + ); + } + let update = snapshot.can_update + && snapshot + .version + .as_ref() + .is_some_and(devices::ToolVersion::needs_update); + if update || snapshot.can_inspect { + let mut actions = h_flex().mt_3().flex_wrap().gap_2(); + if update && let Some(version) = &snapshot.version { + let updating = snapshot.pending == Some(Versions::Update(tool)); + let label = if updating { + "Updating\u{2026}".to_owned() + } else { + format!("Update to v{}", version.required) + }; + let view = view.clone(); + actions = actions.child( + button( + ("settings-device-update", tool as usize), + Variant::Primary, + snapshot.enabled, + cx, + ) + .child(label) + .when(snapshot.enabled, |b| { + b.on_click(move |_, _, cx| { + view.update(cx, |this, cx| this.update_tool(tool, cx)) + }) + }), + ); + } + if snapshot.can_inspect { + let checking = snapshot.pending == Some(Versions::Inspect); + let view = view.clone(); + actions = actions.child( + button( + ("settings-device-check", tool as usize), + Variant::Outline, + snapshot.enabled, + cx, + ) + .child(if checking { + "Checking\u{2026}" + } else { + "Check versions" + }) + .when(snapshot.enabled, |b| { + b.on_click(move |_, _, cx| view.update(cx, |this, cx| this.check_versions(cx))) + }), + ); + } + body = body.child(actions); + } + body +} + +impl Render for DevicesPanel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let ctx = self.ctx(cx); + let card = self.devices_card(&ctx, window, cx); + v_flex() + .w_full() + .gap_8() + .child(section("Devices", card)) + .child(self.sources_section(&ctx, cx)) + } +} diff --git a/apps/desktop-gpui/src/ui/settings/devices_panel/editors.rs b/apps/desktop-gpui/src/ui/settings/devices_panel/editors.rs new file mode 100644 index 000000000000..94dc9c31a723 --- /dev/null +++ b/apps/desktop-gpui/src/ui/settings/devices_panel/editors.rs @@ -0,0 +1,1008 @@ +//! The three host editors (DeviceHostEditor.tsx, EasDeviceHostEditor.tsx, +//! SimlockDeviceHostEditor.tsx) as one dialog: their drafts, checks and the host-list writes. + +use super::super::scope_inheritance::{PopTrigger, popup_shell}; +use super::super::tokens::Tokens; +use super::super::*; +use super::DevicesPanel; +use super::widgets::{ + Variant, button, dialog, field, icon_button, labeled, menu_item, note, problem, spinner, + warning, +}; +use crate::settings_devices::{ + self as devices, AddKind, Availability, DeviceConfig, EasHost, SimlockHost, Source, SshHost, +}; +use crate::status::CallEvent; +use gpui::{ElementId, Subscription}; +use gpui_component::input::{InputEvent, InputState}; +use serde_json::{Value, json}; + +type ConnectionKey = (String, Option, Option); + +enum Outcome { + Local, + Connected(Vec), +} + +/// `useDeviceHostConnectionCheck`: the check belongs to the destination it ran against. +enum Check { + Idle, + Running(ConnectionKey), + Done(ConnectionKey, Result), +} + +enum Account { + Idle, + Checking, + Signed { + who: String, + source: String, + apps: Vec<(String, String)>, + }, + Failed(String), +} + +enum Token { + Keep, + Set(String), + Clear, +} + +enum Lists { + Ssh(Vec), + Eas(Vec), + Simlock(Vec), +} + +/// A host-list write: what it sets and what a failure is called. +pub(super) struct Save { + pub(super) title: &'static str, + lists: Lists, +} + +impl Save { + pub(super) fn apply(&self, config: &mut DeviceConfig) { + match &self.lists { + Lists::Ssh(hosts) => config.device_hosts = hosts.clone(), + Lists::Eas(hosts) => config.eas_device_hosts = hosts.clone(), + Lists::Simlock(hosts) => config.simlock_device_hosts = hosts.clone(), + } + } + + pub(super) fn patch(&self) -> Value { + match &self.lists { + Lists::Ssh(hosts) => devices::hosts_patch("deviceHosts", hosts), + Lists::Eas(hosts) => devices::hosts_patch("easDeviceHosts", hosts), + Lists::Simlock(hosts) => devices::hosts_patch("simlockDeviceHosts", hosts), + } + } +} + +/// The write that removes a host. +pub(super) fn removal(source: &Source, config: &DeviceConfig) -> Option { + Some(match source { + Source::Local => return None, + Source::Ssh(host) => Save { + title: "Device host not removed everywhere", + lists: Lists::Ssh( + devices::update_ssh_hosts(&config.device_hosts, host, true, host).ok()?, + ), + }, + Source::Eas(host) => Save { + title: "EAS host not removed everywhere", + lists: Lists::Eas(devices::update_eas_hosts( + &config.eas_device_hosts, + host, + true, + false, + )), + }, + Source::Simlock(host) => Save { + title: "Simlock host not removed everywhere", + lists: Lists::Simlock(devices::update_simlock_hosts( + &config.simlock_device_hosts, + host, + true, + )), + }, + }) +} + +fn has_token(value: &Value) -> bool { + match value { + Value::String(text) => !text.is_empty(), + Value::Null => false, + _ => true, + } +} + +pub(super) struct Editor { + kind: AddKind, + existing: Option, + id: String, + fields: Vec>, + options_open: bool, + pub(super) seq: u64, + check: Check, + account: Account, + app_id: Option, + saved_token: bool, + cleared: bool, + pub(super) app_menu: bool, + pub(super) saving: bool, + pub(super) error: Option, + _subscriptions: Vec, +} + +impl Editor { + pub(super) fn open( + kind: AddKind, + existing: Option<&Source>, + _config: &DeviceConfig, + window: &mut Window, + cx: &mut Context, + ) -> Self { + // placeholder, value, masked + let mut specs: Vec<(&'static str, String, bool)> = Vec::new(); + let mut app_id = None; + let mut saved_token = false; + let mut options_open = false; + let id = match (kind, existing) { + (AddKind::Ssh, existing) => { + let host = match existing { + Some(Source::Ssh(host)) => host.clone(), + _ => SshHost { + kind: "ssh".to_owned(), + id: devices::new_host_id(), + label: String::new(), + target: String::new(), + identity_file: None, + port: None, + }, + }; + options_open = host.port.is_some() || host.identity_file.is_some(); + specs.push(("Mac mini", host.label, false)); + specs.push(("user@host or SSH alias", host.target, false)); + specs.push(( + "SSH config default", + host.identity_file.unwrap_or_default(), + false, + )); + specs.push(( + "Default", + host.port.map(|p| p.to_string()).unwrap_or_default(), + false, + )); + host.id + } + (AddKind::Eas, existing) => { + let host = match existing { + Some(Source::Eas(host)) => host.clone(), + _ => devices::new_eas_host(devices::new_host_id()), + }; + saved_token = has_token(&host.access_token); + app_id = host.app_id.clone(); + specs.push(("EAS Simulator", host.label, false)); + specs.push(("Optional", String::new(), true)); + specs.push(("", host.max_concurrent_sessions.to_string(), false)); + specs.push(("", host.max_session_minutes.to_string(), false)); + specs.push(("", host.idle_minutes.to_string(), false)); + host.id + } + (AddKind::Simlock, existing) => { + let host = match existing { + Some(Source::Simlock(host)) => host.clone(), + _ => devices::new_simlock_host(devices::new_host_id()), + }; + saved_token = has_token(&host.agent_token); + specs.push(("Simlock", host.label, false)); + specs.push(( + "https://gateway.example.ts.net:4700", + host.gateway_url, + false, + )); + specs.push(("Optional", String::new(), true)); + specs.push(("", host.max_leases.to_string(), false)); + specs.push(("", host.max_lease_minutes.to_string(), false)); + host.id + } + }; + let mut subscriptions = Vec::new(); + let fields: Vec> = specs + .into_iter() + .map(|(placeholder, value, masked)| { + let input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(placeholder) + .default_value(value) + .masked(masked) + }); + subscriptions.push(cx.subscribe_in( + &input, + window, + |_: &mut DevicesPanel, _, event: &InputEvent, _, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }, + )); + input + }) + .collect(); + let first = fields[0].read(cx).focus_handle(cx).clone(); + window.focus(&first, cx); + Self { + kind, + existing: existing.cloned(), + id, + fields, + options_open, + seq: 0, + check: Check::Idle, + account: Account::Idle, + app_id, + saved_token, + cleared: false, + app_menu: false, + saving: false, + error: None, + _subscriptions: subscriptions, + } + } + + pub(super) fn wants_account(&self) -> bool { + self.kind == AddKind::Eas + } + + fn text(&self, index: usize, cx: &App) -> String { + self.fields[index].read(cx).value().to_string() + } + + fn number(&self, index: usize, cx: &App) -> u32 { + self.text(index, cx).trim().parse().unwrap_or(0) + } + + fn ssh_draft(&self, cx: &App) -> SshHost { + let port = self.text(3, cx); + let port = port.trim(); + let identity = self.text(2, cx); + SshHost { + kind: "ssh".to_owned(), + id: self.id.clone(), + label: self.text(0, cx), + target: self.text(1, cx), + identity_file: (!identity.trim().is_empty()).then_some(identity), + port: (!port.is_empty()).then(|| port.parse::().unwrap_or(0)), + } + } + + /// `deviceHostConnectionKey`. + fn key_of(host: &SshHost) -> ConnectionKey { + ( + host.target.trim().to_owned(), + host.port, + host.identity_file + .as_deref() + .map(str::trim) + .filter(|file| !file.is_empty()) + .map(str::to_owned), + ) + } + + fn eas_draft(&self, cx: &App) -> EasHost { + EasHost { + kind: "eas".to_owned(), + id: self.id.clone(), + label: self.text(0, cx), + app_id: self.app_id.clone(), + access_token: match self.token(cx) { + Token::Set(token) => json!(token), + _ => json!(""), + }, + max_concurrent_sessions: self.number(2, cx), + max_session_minutes: self.number(3, cx), + idle_minutes: self.number(4, cx), + } + } + + fn simlock_draft(&self, cx: &App) -> SimlockHost { + SimlockHost { + kind: "simlock".to_owned(), + id: self.id.clone(), + label: self.text(0, cx), + gateway_url: self.text(1, cx), + agent_token: match (self.token(cx), &self.existing) { + (Token::Set(token), _) => json!(token), + (Token::Keep, Some(Source::Simlock(host))) => host.agent_token.clone(), + _ => json!(""), + }, + max_leases: self.number(3, cx), + max_lease_minutes: self.number(4, cx), + } + } + + /// Saved (kept), new, or cleared, as the secret field resolves them. + fn token(&self, cx: &App) -> Token { + let typed = self.text(2, cx); + let typed = if self.kind == AddKind::Eas { + self.text(1, cx) + } else { + typed + }; + let typed = typed.trim().to_owned(); + if !typed.is_empty() { + Token::Set(typed) + } else if self.cleared || !self.saved_token { + Token::Clear + } else { + Token::Keep + } + } + + fn token_index(&self) -> usize { + if self.kind == AddKind::Eas { 1 } else { 2 } + } + + pub(super) fn start_test(&mut self, cx: &App) -> Option { + let host = devices::ssh_test_host(&self.ssh_draft(cx))?; + self.seq += 1; + self.check = Check::Running(Self::key_of(&host)); + Some(devices::test_payload(&host)) + } + + pub(super) fn finish_test(&mut self, event: CallEvent) { + let Check::Running(key) = std::mem::replace(&mut self.check, Check::Idle) else { + return; + }; + let result = match event { + CallEvent::Done(value) => Ok(match devices::host_summary(&value) { + Some(summary) if summary.kind != "local" => Outcome::Connected(summary.platforms), + Some(_) => Outcome::Local, + None => Outcome::Connected(Vec::new()), + }), + CallEvent::Failed(message) => Err(message), + CallEvent::Progress(_) => return, + }; + self.check = Check::Done(key, result); + } + + pub(super) fn start_account(&mut self, cx: &App) -> Value { + self.seq += 1; + self.account = Account::Checking; + match self.token(cx) { + Token::Set(token) => json!({"accessToken": token}), + Token::Clear if self.saved_token => json!({"accessToken": ""}), + Token::Keep => match &self.existing { + Some(Source::Eas(host)) => json!({"hostId": host.id}), + _ => json!({}), + }, + Token::Clear => json!({}), + } + } + + pub(super) fn finish_account(&mut self, event: CallEvent) { + self.account = match event { + CallEvent::Done(value) => Account::Signed { + who: value["signedInAs"].as_str().unwrap_or_default().to_owned(), + source: match value["credentialSource"].as_str() { + Some("settings") => "the access token saved here", + Some("env") => "EXPO_TOKEN on the server", + _ => "the server's eas login", + } + .to_owned(), + apps: value["apps"] + .as_array() + .map(|apps| { + apps.iter() + .filter_map(|app| { + Some(( + app["id"].as_str()?.to_owned(), + app["fullName"].as_str()?.to_owned(), + )) + }) + .collect() + }) + .unwrap_or_default(), + }, + CallEvent::Failed(message) => Account::Failed(message), + CallEvent::Progress(_) => return, + }; + } + + /// What Save writes, `None` while the draft cannot be saved. + pub(super) fn save(&mut self, config: &DeviceConfig, cx: &App) -> Option> { + if self.saving || self.checking() { + return None; + } + match self.kind { + AddKind::Ssh => { + let host = devices::parse_ssh_draft(&self.ssh_draft(cx))?; + let original = match &self.existing { + Some(Source::Ssh(original)) => original.clone(), + _ => host.clone(), + }; + Some( + devices::update_ssh_hosts(&config.device_hosts, &host, false, &original).map( + |hosts| Save { + title: "Device host not saved everywhere", + lists: Lists::Ssh(hosts), + }, + ), + ) + } + AddKind::Eas => { + let host = self.eas_draft(cx); + if devices::eas_problem(&host).is_some() { + return None; + } + let keep = matches!(self.token(cx), Token::Keep); + Some(Ok(Save { + title: "EAS host not saved everywhere", + lists: Lists::Eas(devices::update_eas_hosts( + &config.eas_device_hosts, + &host, + false, + keep, + )), + })) + } + AddKind::Simlock => { + let host = self.simlock_draft(cx); + if devices::simlock_problem(&host).is_some() { + return None; + } + Some(Ok(Save { + title: "Simlock host not saved everywhere", + lists: Lists::Simlock(devices::update_simlock_hosts( + &config.simlock_device_hosts, + &host, + false, + )), + })) + } + } + } + + fn checking(&self) -> bool { + matches!(self.check, Check::Running(_)) + } + + fn can_save(&self, cx: &App) -> bool { + if self.saving || self.checking() { + return false; + } + match self.kind { + AddKind::Ssh => devices::parse_ssh_draft(&self.ssh_draft(cx)).is_some(), + AddKind::Eas => devices::eas_problem(&self.eas_draft(cx)).is_none(), + AddKind::Simlock => devices::simlock_problem(&self.simlock_draft(cx)).is_none(), + } + } + + fn secret(&self, view: &Entity, window: &Window, cx: &App) -> AnyElement { + let index = self.token_index(); + let visible = !self.cleared && (self.saved_token || !self.text(index, cx).is_empty()); + let input = self.fields[index].clone(); + let clear = visible.then(|| { + let view = view.clone(); + icon_button( + "device-token-clear", + Variant::GhostDestructive, + !self.saving, + cx, + ) + .tooltip(tooltip("Clear access token")) + .on_click(move |_, window, cx| { + let input = input.clone(); + view.update(cx, |this, cx| { + if let Some(editor) = &mut this.editor { + editor.cleared = true; + } + input.update(cx, |state, cx| state.set_value("", window, cx)); + cx.notify(); + }) + }) + .child(Icon::new(Icons::X).size(px(14.))) + }); + h_flex() + .items_center() + .gap_2() + .child( + div() + .flex_1() + .child(field(&self.fields[index], self.saving, window, cx)), + ) + .children(clear) + .into_any_element() + } + + fn limits( + &self, + labels: &[&'static str], + first: usize, + window: &Window, + cx: &App, + ) -> AnyElement { + h_flex() + .gap_3() + .children(labels.iter().enumerate().map(|(i, title)| { + div().flex_1().child(labeled( + title, + field(&self.fields[first + i], self.saving, window, cx), + )) + })) + .into_any_element() + } + + fn test_box(&self, label: &str, view: &Entity, cx: &App) -> AnyElement { + let theme = Tokens::of(cx); + let draft = self.ssh_draft(cx); + let key = Self::key_of(&draft); + let valid = devices::ssh_test_host(&draft).is_some(); + let (status, row): (String, Option) = match &self.check { + Check::Running(running) if *running == key => ( + "Checking environments\u{2026}".to_owned(), + Some(result_row(label, None, cx)), + ), + Check::Done(done, result) if *done == key => ( + if result.is_ok() { + "Connection checks passed".to_owned() + } else { + "1 of 1 failed".to_owned() + }, + Some(result_row(label, Some(result), cx)), + ), + _ => ("Check access before saving".to_owned(), None), + }; + let enabled = valid && !self.saving && !self.checking(); + let view = view.clone(); + v_flex() + .rounded(px(8.)) + .border_1() + .border_color(theme.border.opacity(0.6)) + .child( + h_flex() + .items_center() + .justify_between() + .gap_3() + .px_3() + .py(px(10.)) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(status), + ) + .child( + button("device-ssh-test", Variant::Outline, enabled, cx) + .child("Test connection") + .when(enabled, |b| { + b.on_click(move |_, _, cx| { + view.update(cx, |this, cx| this.test_connection(cx)) + }) + }), + ), + ) + .children(row) + .into_any_element() + } + + fn account_box(&self, view: &Entity, cx: &App) -> AnyElement { + let theme = Tokens::of(cx); + let (text, color) = match &self.account { + Account::Signed { who, source, .. } => ( + format!("Signed in as {who} with {source}."), + theme.muted_foreground, + ), + Account::Failed(message) => (message.clone(), theme.destructive), + Account::Checking => ( + "Checking the Expo account\u{2026}".to_owned(), + theme.muted_foreground, + ), + Account::Idle => ( + "Check the Expo account before choosing a project.".to_owned(), + theme.muted_foreground, + ), + }; + let enabled = !matches!(self.account, Account::Checking) && !self.saving; + let view = view.clone(); + h_flex() + .items_center() + .justify_between() + .gap_3() + .rounded(px(8.)) + .border_1() + .border_color(theme.border.opacity(0.6)) + .px_3() + .py(px(10.)) + .child( + h_flex() + .min_w_0() + .items_center() + .gap_2() + .text_sm() + .text_color(color) + .when(matches!(self.account, Account::Checking), |line| { + line.child(spinner(14., color)) + }) + .child(div().min_w_0().child(text)), + ) + .child( + button("device-eas-check", Variant::Outline, enabled, cx) + .child("Check account") + .when(enabled, |b| { + b.on_click(move |_, _, cx| { + view.update(cx, |this, cx| this.check_account(cx)) + }) + }), + ) + .into_any_element() + } + + fn project_select(&self, view: &Entity, cx: &App) -> AnyElement { + let theme = Tokens::of(cx); + let mut options: Vec<(Option, String)> = + vec![(None, "From the thread's project".to_owned())]; + let apps = match &self.account { + Account::Signed { apps, .. } => apps.clone(), + _ => Vec::new(), + }; + if let Some(saved) = &self.app_id + && !apps.iter().any(|(id, _)| id == saved) + { + options.push((Some(saved.clone()), saved.clone())); + } + options.extend(apps.into_iter().map(|(id, name)| (Some(id), name))); + let current = options + .iter() + .find(|(id, _)| *id == self.app_id) + .map_or_else( + || "From the thread's project".to_owned(), + |(_, name)| name.clone(), + ); + let selected = self.app_id.clone(); + let trigger = h_flex() + .id("device-eas-app") + .h(px(32.)) + .w_full() + .items_center() + .justify_between() + .px(px(11.)) + .rounded(px(8.)) + .border_1() + .border_color(theme.input) + .bg(if theme.dark { + theme.input.opacity(0.32) + } else { + theme.background + }) + .text_size(px(14.)) + .cursor_pointer() + .child(div().min_w_0().truncate().child(current)) + .child( + div() + .opacity(0.7) + .child(Icon::new(Icons::ChevronDown).size(px(14.))), + ); + let (opened, picked) = (view.clone(), view.clone()); + Popover::new("device-eas-app-menu") + .anchor(Anchor::TopLeft) + .offset(px(4.)) + .open(self.app_menu && !self.saving) + .p_0() + .appearance(false) + .on_open_change(move |next, _, cx| { + opened.update(cx, |this, cx| { + if let Some(editor) = &mut this.editor { + editor.app_menu = *next; + } + cx.notify(); + }) + }) + .trigger(PopTrigger::new(trigger)) + .content(move |_, _, cx| { + popup_shell(cx) + .w(px(448.)) + .max_w_full() + .max_h(px(240.)) + .p_1() + .children(options.iter().enumerate().map(|(index, (id, name))| { + let (view, id) = (picked.clone(), id.clone()); + menu_item(("device-eas-app-item", index), name.clone(), false, cx) + .when(id == selected, |item| item.font_weight(FontWeight::MEDIUM)) + .on_click(move |_, _, cx| { + let id = id.clone(); + view.update(cx, |this, cx| { + if let Some(editor) = &mut this.editor { + editor.app_id = id; + editor.app_menu = false; + } + cx.notify(); + }) + }) + })) + }) + .into_any_element() + } + + pub(super) fn view( + &self, + label: &str, + view: &Entity, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let editing = self.existing.is_some(); + let mut body: Vec = Vec::new(); + let (id, title, description): (&'static str, &'static str, String) = match self.kind { + AddKind::Ssh => ( + "device-host-dialog", + if editing { "Edit device host" } else { "Add device host" }, + format!("Connect from {label}. Hosts on the same machine are skipped."), + ), + AddKind::Eas => ( + "eas-host-dialog", + if editing { "Edit EAS host" } else { "Add EAS host" }, + "The environment's server signs in to Expo and starts the sessions. Without a token here it uses EXPO_TOKEN, then its eas login.".to_owned(), + ), + AddKind::Simlock => ( + "simlock-host-dialog", + if editing { "Edit Simlock host" } else { "Add Simlock host" }, + "The environment's server leases devices from your Simlock gateway and releases them when they close.".to_owned(), + ), + }; + body.push( + labeled("Name", field(&self.fields[0], self.saving, window, cx)).into_any_element(), + ); + let mut problem_text: Option = self.error.clone(); + match self.kind { + AddKind::Ssh => { + body.push( + labeled( + "SSH target", + field(&self.fields[1], self.saving, window, cx), + ) + .into_any_element(), + ); + body.push(self.options(view, window, cx)); + body.push(self.test_box(label, view, cx)); + } + AddKind::Eas => { + body.push( + labeled("Expo access token", self.secret(view, window, cx)).into_any_element(), + ); + body.push(self.account_box(view, cx)); + body.push(labeled("EAS project", self.project_select(view, cx)).into_any_element()); + body.push( + note( + "Sessions bill the project's Expo account. \"From the thread's project\" reads extra.eas.projectId from the project's app config.", + cx, + ) + .into_any_element(), + ); + body.push(self.limits( + &["Sessions at once", "Session minutes", "Idle minutes"], + 2, + window, + cx, + )); + body.push( + note( + format!( + "Expo allows sessions of up to 40 minutes on free accounts and 115 on paid ones, and ends one after the idle minutes without activity, so idle minutes must be smaller than session minutes.{}", + if editing { " Saving restarts this host, which stops its running sessions." } else { "" } + ), + cx, + ) + .into_any_element(), + ); + problem_text = problem_text + .or_else(|| devices::eas_problem(&self.eas_draft(cx)).map(str::to_owned)); + } + AddKind::Simlock => { + body.push( + labeled( + "Gateway URL", + field(&self.fields[1], self.saving, window, cx), + ) + .into_any_element(), + ); + body.push(labeled("Agent token", self.secret(view, window, cx)).into_any_element()); + body.push( + note( + "Leases, drives, and streams devices. Create one with `simlock --gateway token create --role agent`, or leave it blank to add it later. A blank token uses the machine's `AGENT_DEVICE_DAEMON_AUTH_TOKEN` when its `AGENT_DEVICE_DAEMON_BASE_URL` is this gateway.", + cx, + ) + .into_any_element(), + ); + body.push(self.limits(&["Devices at once", "Lease minutes"], 3, window, cx)); + body.push( + note( + format!( + "A lease ends after the lease minutes even while in use.{}", + if editing { + " Saving restarts this host, which releases its devices." + } else { + "" + } + ), + cx, + ) + .into_any_element(), + ); + problem_text = problem_text.or_else(|| { + devices::simlock_problem(&self.simlock_draft(cx)).map(str::to_owned) + }); + } + } + if let Some(text) = problem_text { + body.push(problem(text, cx).into_any_element()); + } + let can_save = self.can_save(cx); + let saving = self.saving; + let (cancel, save, dismiss) = (view.clone(), view.clone(), view.clone()); + let footer = vec![ + button("device-editor-cancel", Variant::Ghost, !saving, cx) + .child("Cancel") + .when(!saving, |b| { + b.on_click(move |_, window, cx| { + cancel.update(cx, |this, cx| { + this.close_menu(window, cx); + }) + }) + }) + .into_any_element(), + button("device-editor-save", Variant::Primary, can_save, cx) + .child("Save host") + .when(can_save, |b| { + b.on_click(move |_, _, cx| save.update(cx, |this, cx| this.save_editor(cx))) + }) + .into_any_element(), + ]; + dialog( + id, + title, + description, + body, + footer, + move |window, cx| { + dismiss.update(cx, |this, cx| { + this.close_menu(window, cx); + }) + }, + cx, + ) + } + + /// The collapsible "SSH options" (DeviceHostEditor.tsx). + fn options(&self, view: &Entity, window: &Window, cx: &App) -> AnyElement { + let theme = Tokens::of(cx); + let open = self.options_open; + let chevron = Icon::new(Icons::ChevronRight).size(px(14.)); + let chevron = if open { + chevron.rotate(gpui::radians(std::f32::consts::FRAC_PI_2)) + } else { + chevron + }; + let toggle = view.clone(); + let header = h_flex() + .id(ElementId::from("device-ssh-options")) + .items_center() + .gap_1() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(theme.muted_foreground) + .cursor_pointer() + .on_click(move |_, _, cx| { + toggle.update(cx, |this, cx| { + if let Some(editor) = &mut this.editor { + editor.options_open = !editor.options_open; + } + cx.notify(); + }) + }) + .child(chevron) + .child("SSH options"); + v_flex() + .gap_3() + .child(header) + .when(open, |options| { + options + .child( + h_flex() + .gap_3() + .child(div().flex_1().child(labeled( + "Identity file", + field(&self.fields[2], self.saving, window, cx), + ))) + .child(div().w(px(112.)).child(labeled( + "Port", + field(&self.fields[3], self.saving, window, cx), + ))), + ) + .child(note( + "Optional. Resolved separately on each environment.", + cx, + )) + }) + .into_any_element() + } +} + +/// A result of the connection check (`px-3 py-2.5`, `text-xs`). +fn result_row(label: &str, result: Option<&Result>, cx: &App) -> AnyElement { + let theme = Tokens::of(cx); + let (status, color, detail): (String, _, Option) = match result { + None => ("Checking\u{2026}".to_owned(), theme.muted_foreground, None), + Some(Ok(Outcome::Local)) => ( + "Already available locally".to_owned(), + theme.muted_foreground, + None, + ), + Some(Ok(Outcome::Connected(platforms))) => ( + "Connected".to_owned(), + warning_free(&theme), + (!platforms.is_empty()).then(|| { + platforms + .iter() + .map(|p| { + let name = match p.platform { + devices::Platform::Ios => "iOS", + devices::Platform::Android => "Android", + }; + format!( + "{name} {}", + if p.available { + "available" + } else { + "not available" + } + ) + }) + .collect::>() + .join(", ") + }), + ), + Some(Err(message)) => ( + "Failed".to_owned(), + theme.destructive, + Some(message.clone()), + ), + }; + v_flex() + .gap_1() + .px_3() + .py(px(10.)) + .border_t_1() + .border_color(theme.border.opacity(0.6)) + .text_xs() + .child( + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + div() + .min_w_0() + .truncate() + .font_weight(FontWeight::MEDIUM) + .child(label.to_owned()), + ) + .child(div().text_color(color).child(status)), + ) + .children(detail.map(|detail| { + div() + .text_color(if result.is_some_and(Result::is_err) { + theme.destructive + } else { + theme.muted_foreground + }) + .child(detail) + })) + .into_any_element() +} + +/// The text colour of a passing check: the foreground, since the web has no success tone here. +fn warning_free(theme: &Tokens) -> gpui::Hsla { + let _ = warning; + theme.foreground +} diff --git a/apps/desktop-gpui/src/ui/settings/devices_panel/widgets.rs b/apps/desktop-gpui/src/ui/settings/devices_panel/widgets.rs new file mode 100644 index 000000000000..89dde218ea65 --- /dev/null +++ b/apps/desktop-gpui/src/ui/settings/devices_panel/widgets.rs @@ -0,0 +1,307 @@ +//! The small controls of the Devices tab: `Button` (button.tsx), `Input` (input.tsx), +//! `Spinner`, the menu rows of a popup and the dialog frame (dialog.tsx). + +use super::super::tokens::Tokens; +use super::super::*; +use gpui::{ElementId, Hsla}; +use gpui_component::Size; +use gpui_component::input::Input; +use gpui_component::spinner::Spinner; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Variant { + Primary, + Outline, + Ghost, + GhostMuted, + GhostDestructive, +} + +/// `--warning-foreground` (index.css:1165, 1217): amber-700, amber-400 in dark. +pub(super) fn warning(theme: &Tokens) -> Hsla { + gpui::rgb(if theme.dark { 0xfbbf24 } else { 0xb45309 }).into() +} + +/// `Button size="sm"` (`h-7 gap-1.5 rounded-lg px-[9px] text-sm`): the caller adds the content +/// and `on_click`. +pub(super) fn button( + id: impl Into, + variant: Variant, + enabled: bool, + cx: &App, +) -> gpui::Stateful { + let theme = Tokens::of(cx); + let (background, border, text, hover) = match variant { + Variant::Primary => ( + theme.primary, + theme.primary, + gpui::white(), + theme.primary.opacity(0.9), + ), + Variant::Outline if theme.dark => ( + theme.input.opacity(0.32), + theme.input, + theme.foreground, + theme.input.opacity(0.64), + ), + Variant::Outline => ( + theme.popover, + theme.input, + theme.foreground, + theme.accent.opacity(0.5), + ), + Variant::Ghost => ( + gpui::transparent_black(), + gpui::transparent_black(), + theme.foreground, + theme.accent, + ), + Variant::GhostMuted | Variant::GhostDestructive => ( + gpui::transparent_black(), + gpui::transparent_black(), + theme.muted_foreground, + theme.accent, + ), + }; + let hover_text = match variant { + Variant::GhostMuted => Some(theme.foreground), + Variant::GhostDestructive => Some(theme.destructive), + _ => None, + }; + div() + .id(id) + .h(px(28.)) + .px(px(9.)) + .flex() + .flex_shrink_0() + .items_center() + .justify_center() + .gap(px(6.)) + .rounded(px(8.)) + .border_1() + .border_color(border) + .bg(background) + .text_color(text) + .text_size(px(14.)) + .line_height(px(20.)) + .font_weight(FontWeight::MEDIUM) + .whitespace_nowrap() + .when(enabled, |button| { + button.cursor_pointer().hover(move |style| { + let style = style.bg(hover); + match hover_text { + Some(color) => style.text_color(color), + None => style, + } + }) + }) + .when(!enabled, |button| button.opacity(0.64)) +} + +/// `Button size="icon-sm"` (`size-8 sm:size-7`). +pub(super) fn icon_button( + id: impl Into, + variant: Variant, + enabled: bool, + cx: &App, +) -> gpui::Stateful { + button(id, variant, enabled, cx).size(px(28.)).p_0() +} + +/// `Spinner` (spinner.tsx): the glyph without the continuous spin the app avoids. +pub(super) fn spinner(size: f32, color: Hsla) -> AnyElement { + Spinner::new() + .icon(Icon::new(Icons::LoaderCircle)) + .color(color) + .with_size(Size::Size(px(size))) + .into_any_element() +} + +/// `Input` in its default size: `h-8 rounded-lg border bg-popover`, `ring` while focused. +pub(super) fn field( + input: &Entity, + disabled: bool, + window: &Window, + cx: &App, +) -> gpui::Div { + let theme = Tokens::of(cx); + let focused = input.read(cx).focus_handle(cx).contains_focused(window, cx); + let background = if theme.dark { + theme.input.opacity(0.32) + } else { + theme.background + }; + div() + .w_full() + .h(px(32.)) + .px(px(11.)) + .rounded(px(8.)) + .border_1() + .border_color(if focused { theme.ring } else { theme.input }) + .bg(background) + .text_size(px(14.)) + .when(disabled, |field| field.opacity(0.64)) + .child( + Input::new(input) + .appearance(false) + .bordered(false) + .disabled(disabled) + .h(px(30.)) + .px_0(), + ) +} + +/// `label.block.space-y-1.5.text-sm`. +pub(super) fn labeled(title: &'static str, body: impl IntoElement) -> gpui::Div { + v_flex() + .gap(px(6.)) + .text_size(px(14.)) + .child(div().child(title)) + .child(body) +} + +/// A line of `text-xs text-muted-foreground`. +pub(super) fn note(text: impl IntoElement, cx: &App) -> gpui::Div { + div() + .text_xs() + .line_height(px(16.)) + .text_color(Tokens::of(cx).muted_foreground) + .child(text) +} + +/// `p.text-xs.text-destructive[role=alert]`. +pub(super) fn problem(text: impl IntoElement, cx: &App) -> gpui::Div { + div() + .text_xs() + .line_height(px(16.)) + .text_color(Tokens::of(cx).destructive) + .child(text) +} + +/// An item of a popup menu (menu.tsx): `min-h-7 rounded-sm px-2 py-1 text-sm`. +pub(super) fn menu_item( + id: impl Into, + label: impl Into, + destructive: bool, + cx: &App, +) -> gpui::Stateful { + let theme = Tokens::of(cx); + let accent = theme.accent; + div() + .id(id) + .min_h(px(28.)) + .flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .rounded(px(4.)) + .text_sm() + .cursor_pointer() + .text_color(if destructive { + theme.destructive + } else { + theme.foreground + }) + .hover(move |style| style.bg(accent)) + .child(label.into()) +} + +/// `DialogPopup` (dialog.tsx:99-165): a `max-w-lg` card over the backdrop with a header, a +/// scrolling panel and a footer on `muted`. `dismiss` runs on a backdrop click. +pub(super) fn dialog( + id: &'static str, + title: &'static str, + description: impl IntoElement, + body: Vec, + footer: Vec, + dismiss: impl Fn(&mut Window, &mut App) + 'static, + cx: &App, +) -> AnyElement { + let theme = Tokens::of(cx); + let border = if theme.dark { + gpui::white().opacity(0.08) + } else { + theme.foreground.opacity(0.1) + }; + let header = v_flex() + .gap_2() + .px_6() + .pt_6() + .pb_3() + .child( + div() + .text_xl() + .font_weight(FontWeight::SEMIBOLD) + .child(title), + ) + .child( + div() + .text_size(px(14.)) + .line_height(px(20.)) + .text_color(theme.muted_foreground) + .child(description), + ); + let panel = v_flex() + .id(ElementId::from((id, 1usize))) + .min_h_0() + .overflow_y_scroll() + .gap_4() + .px_6() + .pt(px(4.)) + .pb(px(4.)) + .children(body); + let footer = h_flex() + .justify_end() + .gap_2() + .px_6() + .py_4() + .border_t_1() + .border_color(theme.border) + .bg(theme.accent) + .children(footer); + div() + .absolute() + .inset_0() + .child( + div() + .id(ElementId::from((id, 2usize))) + .absolute() + .inset_0() + .occlude() + .bg(theme.backdrop()) + .on_click(move |_, window, cx| dismiss(window, cx)), + ) + .child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .p_4() + .child( + v_flex() + .occlude() + .w(px(512.)) + .max_w_full() + .max_h_full() + .overflow_hidden() + .rounded(px(16.)) + .border_1() + .border_color(border) + .bg(theme.background) + .shadow(vec![gpui::BoxShadow { + color: gpui::black().opacity(if theme.dark { 0.9 } else { 0.65 }), + offset: gpui::point(px(0.), px(24.)), + blur_radius: px(if theme.dark { 72. } else { 64. }), + spread_radius: px(if theme.dark { -20. } else { -24. }), + inset: false, + }]) + .child(header) + .child(panel) + .child(footer), + ), + ) + .into_any_element() +} diff --git a/apps/desktop-gpui/src/ui/settings/labs_panel.rs b/apps/desktop-gpui/src/ui/settings/labs_panel.rs index 84b30c7519c1..ca99ad1953a7 100644 --- a/apps/desktop-gpui/src/ui/settings/labs_panel.rs +++ b/apps/desktop-gpui/src/ui/settings/labs_panel.rs @@ -6,14 +6,13 @@ use super::components::{ HoverHandler, KeyHandler, Menu, PickHandler, SelectTrigger, group, row, section, select_menu, switch, }; -use super::scope_pickers::{Picker, picker}; +use super::scope_pickers::ScopeBar; use super::tokens::Tokens; use super::*; use crate::connection::{Action, ActionSender, Call}; use crate::settings_labs::{ - self as labs, ALL, Choice, ChoiceIcon, GUEST_LINKS, LINKED_ENVIRONMENTS, LabsSettings, Link, - Piece, Reach, SIMLOCK_HOSTS, ScopeData, ScopeSearch, ServerFlags, ServerKey, ToolCalls, - ToolCallsDefault, + self as labs, GUEST_LINKS, LINKED_ENVIRONMENTS, LabsSettings, Link, Piece, Reach, + SIMLOCK_HOSTS, ServerFlags, ServerKey, ToolCalls, ToolCallsDefault, }; use gpui::{ EventEmitter, FocusHandle, HighlightStyle, InteractiveText, KeyDownEvent, StyledText, @@ -28,22 +27,12 @@ pub enum LabsEvent { OpenSection(Section), } -#[derive(Clone, Copy, PartialEq, Eq)] -enum Axis { - Project, - Environment, -} - pub struct LabsPanel { actions: ActionSender, settings: LabsSettings, flags: ServerFlags, - data: ScopeData, - scope: ScopeSearch, + scope: Entity, wide: bool, - open: Option, - project_search: Entity, - environment_search: Entity, tool_menu: Option, tool_trigger: FocusHandle, /// Server writes in flight: the key and the value to fall back to. @@ -55,43 +44,25 @@ pub struct LabsPanel { impl EventEmitter for LabsPanel {} impl LabsPanel { - pub fn new(actions: ActionSender, window: &mut Window, cx: &mut Context) -> Self { - let project_search = - cx.new(|cx| InputState::new(window, cx).placeholder("Search projects")); - let environment_search = - cx.new(|cx| InputState::new(window, cx).placeholder("Search environments")); - let subscriptions = [&project_search, &environment_search] - .into_iter() - .map(|input| { - cx.subscribe(input, |_, _, event: &InputEvent, cx| { - if matches!(event, InputEvent::Change) { - cx.notify(); - } - }) - }) - .collect(); + pub fn new(actions: ActionSender, scope: Entity, cx: &mut Context) -> Self { + let observed = cx.observe(&scope, |_, _, cx| cx.notify()); Self { actions, settings: LabsSettings::load(), flags: ServerFlags::default(), - data: ScopeData::default(), - scope: ScopeSearch::default(), + scope, wide: true, - open: None, - project_search, - environment_search, tool_menu: None, tool_trigger: cx.focus_handle().tab_stop(true), pending: HashMap::new(), next_token: labs::TOKEN_BASE, - _subscriptions: subscriptions, + _subscriptions: vec![observed], } } /// Re-reads the file and closes any menu, as the dialog does when it opens. pub fn reload(&mut self, cx: &mut Context) { self.settings = LabsSettings::load(); - self.open = None; self.tool_menu = None; cx.notify(); } @@ -103,33 +74,16 @@ impl LabsPanel { } } - /// The attached environment's flags and the scope bar's choices, as the status carries them. - pub fn set_data( - &mut self, - data: ScopeData, - flags: Option, - cx: &mut Context, - ) { - let mut changed = self.data != data; - self.data = data; - if let Some(flags) = flags - && self.pending.is_empty() - && self.flags != flags - { + /// The attached environment's flags, as the status carries them. + pub fn set_flags(&mut self, flags: ServerFlags, cx: &mut Context) { + if self.pending.is_empty() && self.flags != flags { self.flags = flags; - changed = true; - } - if changed { cx.notify(); } } /// Closes an open menu; returns whether there was one, so Escape stops there. pub fn close_menu(&mut self, window: &mut Window, cx: &mut Context) -> bool { - if self.open.take().is_some() { - cx.notify(); - return true; - } if self.tool_menu.take().is_some() { window.focus(&self.tool_trigger, cx); cx.notify(); @@ -159,35 +113,6 @@ impl LabsPanel { cx.notify(); } - fn set_open(&mut self, axis: Axis, opened: bool, window: &mut Window, cx: &mut Context) { - let input = self.search_of(axis).clone(); - if opened { - self.open = Some(axis); - input.update(cx, |input, cx| input.set_value("", window, cx)); - let handle = input.focus_handle(cx); - window.focus(&handle, cx); - } else if self.open == Some(axis) { - self.open = None; - } - cx.notify(); - } - - fn search_of(&self, axis: Axis) -> &Entity { - match axis { - Axis::Project => &self.project_search, - Axis::Environment => &self.environment_search, - } - } - - fn pick_scope(&mut self, axis: Axis, value: &str, cx: &mut Context) { - self.scope = match axis { - Axis::Project => self.scope.select_project(value), - Axis::Environment => self.scope.select_environment(value), - }; - self.open = None; - cx.notify(); - } - fn link(&mut self, link: Link, cx: &mut Context) { match link { Link::Section(section) => cx.emit(LabsEvent::OpenSection(section)), @@ -197,7 +122,11 @@ impl LabsPanel { /// A server switch: the write goes to the environments in scope that this app can write. fn toggle_server(&mut self, key: ServerKey, cx: &mut Context) { - let Reach::Targets(_) = labs::reach(key, &self.scope, &self.data) else { + let Reach::Targets(_) = labs::reach( + key, + self.scope.read(cx).search(), + self.scope.read(cx).data(), + ) else { return; }; let previous = self.flags.get(key); @@ -282,7 +211,11 @@ impl LabsPanel { ) -> AnyElement { let view = cx.entity(); let checked = self.flags.get(key); - match labs::reach(key, &self.scope, &self.data) { + match labs::reach( + key, + self.scope.read(cx).search(), + self.scope.read(cx).data(), + ) { Reach::Targets(_) => switch( id, checked, @@ -420,119 +353,6 @@ impl LabsPanel { }) .into_any_element() } - - fn project_picker(&self, window: &mut Window, cx: &mut Context) -> AnyElement { - let view = cx.entity(); - let selected = self - .scope - .project - .as_ref() - .and_then(|key| self.data.projects.iter().find(|p| &p.key == key)); - let spec = Picker { - id: "settings-scope-project", - label: labs::project_label(&self.scope, &self.data), - icon: selected.map(|p| ChoiceIcon::Project { - initials: p.initials.clone(), - color: p.color, - }), - all: Some(Choice { - value: ALL.to_owned(), - label: "All projects".to_owned(), - secondary: String::new(), - offline: false, - icon: ChoiceIcon::None, - }), - choices: labs::project_choices(&self.scope, &self.data), - selected: self.scope.project.clone().unwrap_or_else(|| ALL.to_owned()), - open: self.open == Some(Axis::Project), - search: self.project_search.clone(), - query: self.project_search.read(cx).value().to_string(), - on_open: { - let view = view.clone(); - Rc::new(move |opened, window, cx| { - view.update(cx, |this, cx| { - this.set_open(Axis::Project, opened, window, cx) - }) - }) - }, - on_pick: Rc::new(move |value, _, cx| { - view.update(cx, |this, cx| this.pick_scope(Axis::Project, &value, cx)) - }), - }; - picker(spec, window, cx) - } - - fn environment_picker(&self, window: &mut Window, cx: &mut Context) -> AnyElement { - let view = cx.entity(); - let selected = self - .scope - .machine - .as_ref() - .and_then(|id| self.data.environments.iter().find(|e| &e.id == id)); - let spec = Picker { - id: "settings-scope-environment", - label: labs::environment_trigger_label(&self.scope, &self.data), - icon: selected.map(|e| ChoiceIcon::Machine(e.machine)), - all: Some(Choice { - value: ALL.to_owned(), - label: "All environments".to_owned(), - secondary: String::new(), - offline: false, - icon: ChoiceIcon::Layers, - }), - choices: labs::environment_choices(&self.scope, &self.data), - selected: self.scope.machine.clone().unwrap_or_else(|| ALL.to_owned()), - open: self.open == Some(Axis::Environment), - search: self.environment_search.clone(), - query: self.environment_search.read(cx).value().to_string(), - on_open: { - let view = view.clone(); - Rc::new(move |opened, window, cx| { - view.update(cx, |this, cx| { - this.set_open(Axis::Environment, opened, window, cx) - }) - }) - }, - on_pick: Rc::new(move |value, _, cx| { - view.update(cx, |this, cx| { - this.pick_scope(Axis::Environment, &value, cx) - }) - }), - }; - picker(spec, window, cx) - } - - /// `SettingsScopeSentence`: `px-3 text-sm text-muted-foreground`, the two halves `gap-1.5`. - fn sentence(&self, window: &mut Window, cx: &mut Context) -> gpui::Div { - let muted = Tokens::of(cx).muted_foreground; - let project = self.project_picker(window, cx); - let environment = self.environment_picker(window, cx); - h_flex() - .min_w_0() - .flex_wrap() - .items_center() - .gap_x(px(6.)) - .gap_y(px(4.)) - .px_3() - .text_sm() - .text_color(muted) - .child( - h_flex() - .min_w_0() - .items_center() - .gap(px(6.)) - .child(div().flex_shrink_0().child("Applying settings for")) - .child(project), - ) - .child( - h_flex() - .min_w_0() - .items_center() - .gap(px(6.)) - .child(div().flex_shrink_0().child(self.scope.joiner())) - .child(environment), - ) - } } impl Render for LabsPanel { @@ -545,7 +365,6 @@ impl Render for LabsPanel { .map(IntoElement::into_any_element) .collect() }; - let sentence = self.sentence(window, cx); let beta = group( rows(vec![ row( @@ -666,7 +485,6 @@ impl Render for LabsPanel { .w_full() .gap_8() .on_key_down(cx.listener(|this, event, window, cx| this.key_down(event, window, cx))) - .child(sentence) .child(section("Beta", beta)) .child(section("Legacy", legacy)) } diff --git a/apps/desktop-gpui/src/ui/settings/scope_inheritance.rs b/apps/desktop-gpui/src/ui/settings/scope_inheritance.rs new file mode 100644 index 000000000000..de7d20d361e4 --- /dev/null +++ b/apps/desktop-gpui/src/ui/settings/scope_inheritance.rs @@ -0,0 +1,272 @@ +//! `SettingInheritance` (SettingInheritance.tsx:166-330): the Layers marker beside a setting's +//! title, its summary tooltip, and the popover that lists where the value comes from. Any tab +//! can reuse it through `marker_row`, a `SettingsRow` (settingsLayout.tsx) with a marker slot. + +use super::components::tab_stop; +use super::tokens::Tokens; +use super::*; +use crate::projects::MachineKind; +use crate::settings_devices::{Layer, State, marker_always_visible}; +use gpui::{BoxShadow, ElementId, point}; + +/// A trigger for `Popover` that draws the element it was given. +#[derive(IntoElement)] +pub(super) struct PopTrigger(AnyElement); + +impl PopTrigger { + pub(super) fn new(element: impl IntoElement) -> Self { + Self(element.into_any_element()) + } +} + +impl Selectable for PopTrigger { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } +} + +impl RenderOnce for PopTrigger { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + self.0 + } +} + +/// `dropdown-glass` popup shell (popover.tsx:73-90): a `rounded-lg` box with a `foreground/10` +/// border and the menu shadow. +pub(super) fn popup_shell(cx: &App) -> gpui::Div { + let theme = Tokens::of(cx); + let shadow = if theme.dark { + (18., 44., 0.8) + } else { + (16., 40., 0.55) + }; + v_flex() + .rounded(px(8.)) + .border_1() + .border_color(theme.foreground.opacity(0.1)) + .bg(theme.popover) + .shadow(vec![BoxShadow { + color: gpui::black().opacity(shadow.2), + offset: point(px(0.), px(shadow.0)), + blur_radius: px(shadow.1), + spread_radius: px(-18.), + inset: false, + }]) +} + +/// What the marker explains. +#[derive(Clone)] +pub(super) struct Marker { + pub id: &'static str, + pub state: State, + pub summary: String, + pub layers: Vec, + pub machine: MachineKind, + pub environment: String, +} + +/// The popover body (SettingInheritance.tsx:263-329, one environment): a muted header with the +/// machine icon and name, then the layers, the effective one highlighted and checked. +fn chain(marker: &Marker, cx: &App) -> gpui::Div { + let theme = Tokens::of(cx); + let header = h_flex() + .items_center() + .gap(px(6.)) + .pb(px(6.)) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(theme.muted_foreground) + .child( + div() + .flex_shrink_0() + .child(Icon::new(machine_icon(marker.machine)).size(px(14.))), + ) + .child(div().min_w_0().truncate().child(marker.environment.clone())); + let layers = v_flex() + .text_sm() + .children(marker.layers.iter().map(|layer| { + let label_color = if layer.effective { + theme.foreground + } else { + theme.muted_foreground + }; + let value_color = if layer.effective { + theme.foreground + } else if layer.set { + theme.muted_foreground + } else { + theme.muted_foreground.opacity(0.6) + }; + let label = match layer.kind { + crate::settings_devices::LayerKind::Environment => "Environment".to_owned(), + _ => layer.label.clone(), + }; + h_flex() + .items_center() + .justify_between() + .gap(px(12.)) + .rounded(px(6.)) + .px_2() + .py_1() + .when(layer.effective, |row| { + row.bg(theme.foreground.opacity(0.06)) + }) + .child( + div() + .min_w_0() + .truncate() + .text_color(label_color) + .when(layer.effective, |label| { + label.font_weight(FontWeight::MEDIUM) + }) + .child(label), + ) + .child( + h_flex() + .items_center() + .gap(px(6.)) + .text_color(value_color) + .child(div().max_w(px(128.)).truncate().child(layer.value.clone())) + .child( + div() + .size(px(14.)) + .flex_shrink_0() + .text_color(theme.primary) + .when(layer.effective, |slot| { + slot.child(Icon::new(Icons::Check).size(px(14.))) + }), + ), + ) + })); + v_flex() + .w(px(320.)) + .max_w_full() + .px(px(12.)) + .py(px(10.)) + .child(header) + .child(layers) +} + +/// The Layers button: `Button size="icon-micro" variant="ghost-muted"` (`size-5 rounded-sm`) +/// holding a `size-3` icon, `text-primary` while overridden, with the summary as its tooltip. +fn indicator(marker: &Marker, window: &mut Window, cx: &mut App) -> AnyElement { + let key = ElementId::from(marker.id); + let handle = tab_stop(&key, window, cx); + let open = window.use_keyed_state(ElementId::from((key.clone(), "open")), cx, |_, _| false); + let opened = *open.read(cx); + let theme = Tokens::of(cx); + let (muted, foreground, accent, primary) = ( + theme.muted_foreground, + theme.foreground, + theme.accent, + theme.primary, + ); + let overridden = marker.state == State::Overridden; + let summary = marker.summary.clone(); + let button = div() + .id(key) + .track_focus(&handle) + .size(px(20.)) + .flex() + .items_center() + .justify_center() + .rounded(px(4.)) + .text_color(if overridden { primary } else { muted }) + .cursor_pointer() + .hover(move |style| { + style + .bg(accent) + .text_color(if overridden { primary } else { foreground }) + }) + .tooltip(tooltip(summary)) + .child(Icon::new(Icons::Layers).size(px(12.))); + let marker = marker.clone(); + let toggled = open.clone(); + Popover::new(ElementId::from((marker.id, 2usize))) + .anchor(Anchor::TopLeft) + .offset(px(4.)) + .open(opened) + .p_0() + .appearance(false) + .on_open_change(move |next, _, cx| toggled.update(cx, |open, _| *open = *next)) + .trigger(PopTrigger::new(button)) + .content(move |_, _, cx| popup_shell(cx).child(chain(&marker, cx))) + .into_any_element() +} + +/// `SettingsRow` with a marker slot between the title and the reset slot (settingsLayout.tsx: +/// 455-459): the marker waits for hover, focus or an open popover unless it explains an +/// inherited or overridden value. +#[allow(clippy::too_many_arguments)] +pub(super) fn marker_row( + id: &'static str, + title: &'static str, + description: impl IntoElement, + marker: Option, + reset: Option, + control: AnyElement, + wide: bool, + window: &mut Window, + cx: &mut App, +) -> gpui::Stateful { + let theme = Tokens::of(cx); + let slot = marker.map(|marker| { + let always = marker_always_visible(marker.state); + let key = ElementId::from(marker.id); + let handle = tab_stop(&key, window, cx); + let open = *window + .use_keyed_state(ElementId::from((key, "open")), cx, |_, _| false) + .read(cx); + let visible = always || open || handle.is_focused(window); + let button = indicator(&marker, window, cx); + div() + .size(px(20.)) + .flex_shrink_0() + .when(!visible, |slot| { + slot.opacity(0.).group_hover(id, |style| style.opacity(1.)) + }) + .child(button) + }); + let heading = h_flex() + .min_h(px(20.)) + .items_center() + .gap(px(6.)) + .child( + div() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(theme.foreground) + .child(title), + ) + .children(slot) + .child(div().size(px(20.)).flex_shrink_0().children(reset)); + let text = v_flex().min_w_0().flex_1().gap_1().child(heading).child( + div() + .max_w(px(672.)) + .text_xs() + .line_height(px(18.)) + .text_color(theme.muted_foreground) + .child(description), + ); + let control = h_flex() + .min_w_0() + .flex_shrink_0() + .items_center() + .gap_2() + .justify_end() + .child(control); + let inner = if wide { + h_flex() + .items_center() + .gap(px(32.)) + .child(text) + .child(div().min_w(px(160.)).child(control)) + } else { + v_flex().gap_3().child(text).child(control) + }; + div().id(id).group(id).px_4().py_3().child(inner) +} diff --git a/apps/desktop-gpui/src/ui/settings/scope_pickers.rs b/apps/desktop-gpui/src/ui/settings/scope_pickers.rs index 8c02b41e3f27..291b73933d5c 100644 --- a/apps/desktop-gpui/src/ui/settings/scope_pickers.rs +++ b/apps/desktop-gpui/src/ui/settings/scope_pickers.rs @@ -6,7 +6,7 @@ use super::components::tab_stop; use super::tokens::Tokens; use super::*; -use crate::settings_labs::{self as labs, Choice, ChoiceIcon}; +use crate::settings_labs::{self as labs, ALL, Choice, ChoiceIcon, ScopeData, ScopeSearch}; use gpui::{BoxShadow, ElementId, FocusHandle, KeyDownEvent, point}; use std::rc::Rc; @@ -324,3 +324,221 @@ pub(super) fn picker(spec: Picker, window: &mut Window, cx: &mut App) -> AnyElem }) .into_any_element() } + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Axis { + Project, + Environment, +} + +/// The settings scope sentence and its state, shared by every tab that applies a scope +/// (`SettingsScopeContext`). The dialog owns one and draws it above the panel. +pub(super) struct ScopeBar { + search: ScopeSearch, + data: ScopeData, + open: Option, + project_search: Entity, + environment_search: Entity, + _subscriptions: Vec, +} + +impl ScopeBar { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let project_search = + cx.new(|cx| InputState::new(window, cx).placeholder("Search projects")); + let environment_search = + cx.new(|cx| InputState::new(window, cx).placeholder("Search environments")); + let subscriptions = [&project_search, &environment_search] + .into_iter() + .map(|input| { + cx.subscribe(input, |_, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }) + }) + .collect(); + Self { + search: ScopeSearch::default(), + data: ScopeData::default(), + open: None, + project_search, + environment_search, + _subscriptions: subscriptions, + } + } + + pub fn search(&self) -> &ScopeSearch { + &self.search + } + + pub fn data(&self) -> &ScopeData { + &self.data + } + + pub fn set_data(&mut self, data: ScopeData, cx: &mut Context) { + if self.data != data { + self.data = data; + cx.notify(); + } + } + + /// Closes an open picker; returns whether there was one, so Escape stops there. + pub fn close_menu(&mut self, cx: &mut Context) -> bool { + let was = self.open.take().is_some(); + if was { + cx.notify(); + } + was + } + + fn input(&self, axis: Axis) -> &Entity { + match axis { + Axis::Project => &self.project_search, + Axis::Environment => &self.environment_search, + } + } + + fn set_open(&mut self, axis: Axis, opened: bool, window: &mut Window, cx: &mut Context) { + let input = self.input(axis).clone(); + if opened { + self.open = Some(axis); + input.update(cx, |input, cx| input.set_value("", window, cx)); + let handle = input.focus_handle(cx); + window.focus(&handle, cx); + } else if self.open == Some(axis) { + self.open = None; + } + cx.notify(); + } + + fn pick(&mut self, axis: Axis, value: &str, cx: &mut Context) { + self.search = match axis { + Axis::Project => self.search.select_project(value), + Axis::Environment => self.search.select_environment(value), + }; + self.open = None; + cx.notify(); + } + + fn project_picker(&self, window: &mut Window, cx: &mut Context) -> AnyElement { + let view = cx.entity(); + let selected = self + .search + .project + .as_ref() + .and_then(|key| self.data.projects.iter().find(|p| &p.key == key)); + let spec = Picker { + id: "settings-scope-project", + label: labs::project_label(&self.search, &self.data), + icon: selected.map(|p| ChoiceIcon::Project { + initials: p.initials.clone(), + color: p.color, + }), + all: Some(Choice { + value: ALL.to_owned(), + label: "All projects".to_owned(), + secondary: String::new(), + offline: false, + icon: ChoiceIcon::None, + }), + choices: labs::project_choices(&self.search, &self.data), + selected: self + .search + .project + .clone() + .unwrap_or_else(|| ALL.to_owned()), + open: self.open == Some(Axis::Project), + search: self.project_search.clone(), + query: self.project_search.read(cx).value().to_string(), + on_open: { + let view = view.clone(); + Rc::new(move |opened, window, cx| { + view.update(cx, |this, cx| { + this.set_open(Axis::Project, opened, window, cx) + }) + }) + }, + on_pick: Rc::new(move |value, _, cx| { + view.update(cx, |this, cx| this.pick(Axis::Project, &value, cx)) + }), + }; + picker(spec, window, cx) + } + + fn environment_picker(&self, window: &mut Window, cx: &mut Context) -> AnyElement { + let view = cx.entity(); + let selected = self + .search + .machine + .as_ref() + .and_then(|id| self.data.environments.iter().find(|e| &e.id == id)); + let spec = Picker { + id: "settings-scope-environment", + label: labs::environment_trigger_label(&self.search, &self.data), + icon: selected.map(|e| ChoiceIcon::Machine(e.machine)), + all: Some(Choice { + value: ALL.to_owned(), + label: "All environments".to_owned(), + secondary: String::new(), + offline: false, + icon: ChoiceIcon::Layers, + }), + choices: labs::environment_choices(&self.search, &self.data), + selected: self + .search + .machine + .clone() + .unwrap_or_else(|| ALL.to_owned()), + open: self.open == Some(Axis::Environment), + search: self.environment_search.clone(), + query: self.environment_search.read(cx).value().to_string(), + on_open: { + let view = view.clone(); + Rc::new(move |opened, window, cx| { + view.update(cx, |this, cx| { + this.set_open(Axis::Environment, opened, window, cx) + }) + }) + }, + on_pick: Rc::new(move |value, _, cx| { + view.update(cx, |this, cx| this.pick(Axis::Environment, &value, cx)) + }), + }; + picker(spec, window, cx) + } +} + +impl Render for ScopeBar { + /// `SettingsScopeSentence`: `px-3 text-sm text-muted-foreground`, the two halves `gap-1.5`. + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let muted = Tokens::of(cx).muted_foreground; + let project = self.project_picker(window, cx); + let environment = self.environment_picker(window, cx); + h_flex() + .min_w_0() + .flex_wrap() + .items_center() + .gap_x(px(6.)) + .gap_y(px(4.)) + .px_3() + .text_sm() + .text_color(muted) + .child( + h_flex() + .min_w_0() + .items_center() + .gap(px(6.)) + .child(div().flex_shrink_0().child("Applying settings for")) + .child(project), + ) + .child( + h_flex() + .min_w_0() + .items_center() + .gap(px(6.)) + .child(div().flex_shrink_0().child(self.search.joiner())) + .child(environment), + ) + } +} From b693491d5893d678fd2fa927a5de742dc7953617 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 9 Nov 2026 02:49:55 -0500 Subject: [PATCH 66/82] fix(desktop-gpui): settings scope environments show their real host and connection state The environment picker showed a placeholder address for the attached environment and the id for saved ones, and guessed offline status from having no projects. It now derives host:port from each environment's URL as the web does and reads the live connection state. --- apps/desktop-gpui/src/config.rs | 8 +++ apps/desktop-gpui/src/environments.rs | 8 +-- apps/desktop-gpui/src/projects.rs | 6 +++ apps/desktop-gpui/src/settings_labs.rs | 68 ++++++++++++++++++++++++-- apps/desktop-gpui/src/ui.rs | 8 ++- 5 files changed, 90 insertions(+), 8 deletions(-) diff --git a/apps/desktop-gpui/src/config.rs b/apps/desktop-gpui/src/config.rs index 866b7984687f..61bda9e3ca56 100644 --- a/apps/desktop-gpui/src/config.rs +++ b/apps/desktop-gpui/src/config.rs @@ -35,6 +35,14 @@ impl Attach { } } +/// The URL of the server this window shows: the attached one, else the one it manages. +pub fn primary_url() -> String { + Attach::from_env() + .map(|attach| attach.http_base) + .or_else(|| Config::from_env().map(|config| config.http_base())) + .unwrap_or_default() +} + /// How to start the server. The command is data: dev runs `node `, a /// packaged build runs `t3` from PATH with no entry. #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/apps/desktop-gpui/src/environments.rs b/apps/desktop-gpui/src/environments.rs index 3bd873a46a20..17a19d7557cc 100644 --- a/apps/desktop-gpui/src/environments.rs +++ b/apps/desktop-gpui/src/environments.rs @@ -55,17 +55,19 @@ async fn follow(store: Arc, saved: StoredCredential, status let forward = async { let mut last: Option = None; while let Ok(update) = own_rx.recv().await { - let projects = match update { + let (projects, connected) = match update { Status::Connected { shell: Some(shell), .. - } => shell.project_entries, + } => (shell.project_entries, true), Status::Connected { .. } => continue, // A dropped socket must not leave a stale "Also on". - _ => Vec::new(), + _ => (Vec::new(), false), }; let next = EnvironmentProjects { id: saved.environment_id.clone(), label: saved.label.clone(), + url: saved.http_base.clone(), + connected, machine, projects, }; diff --git a/apps/desktop-gpui/src/projects.rs b/apps/desktop-gpui/src/projects.rs index 93d80a615d93..2d3c7cf6b671 100644 --- a/apps/desktop-gpui/src/projects.rs +++ b/apps/desktop-gpui/src/projects.rs @@ -73,6 +73,10 @@ pub struct RemoteMember { pub struct EnvironmentProjects { pub id: String, pub label: String, + /// The URL this app reaches it at (`displayUrl`). + pub url: String, + /// Whether its shell stream is up right now. + pub connected: bool, pub machine: MachineKind, pub projects: Vec, } @@ -969,6 +973,8 @@ mod tests { let other = |label: &str, machine, key: &str| EnvironmentProjects { id: label.to_owned(), label: label.to_owned(), + url: String::new(), + connected: true, machine, projects: build_entries( &[project("r", "atlas", |p| { diff --git a/apps/desktop-gpui/src/settings_labs.rs b/apps/desktop-gpui/src/settings_labs.rs index 8c3df33df6a3..3beefaa9c195 100644 --- a/apps/desktop-gpui/src/settings_labs.rs +++ b/apps/desktop-gpui/src/settings_labs.rs @@ -373,6 +373,8 @@ pub struct Environment { pub label: String, /// `host:port`, else the environment id (`settingsScopeEnvironmentAddress`). pub address: String, + /// The URL the address comes from; empty when none is known. + pub display_url: String, pub machine: MachineKind, pub connected: bool, /// Whether this app holds the connection that can write its server settings. @@ -508,8 +510,39 @@ pub enum ChoiceIcon { Project { initials: String, color: usize }, } +/// `settingsScopeEnvironmentAddress`: the URL's host (`new URL(url).host`), else the URL as given, +/// else the environment id. +pub fn environment_address(display_url: &str, id: &str) -> String { + if display_url.is_empty() { + return id.to_owned(); + } + let Some((scheme, rest)) = display_url.split_once("://") else { + return display_url.to_owned(); + }; + let authority = rest.split(['/', '?', '#']).next().unwrap_or_default(); + let host = authority + .rsplit('@') + .next() + .unwrap_or_default() + .to_lowercase(); + let default_port = match scheme.to_lowercase().as_str() { + "http" | "ws" => ":80", + "https" | "wss" => ":443", + _ => "", + }; + let host = match host.strip_suffix(default_port) { + Some(bare) if !default_port.is_empty() => bare, + _ => &host, + }; + if host.is_empty() { + display_url.to_owned() + } else { + host.to_owned() + } +} + /// `settingsScopeEnvironmentLabels`: the secondary text only for a label another environment -/// shares. +/// shares, the full URL when a duplicate also shares the address. fn environment_secondary(environment: &Environment, all: &[Environment]) -> String { let duplicates: Vec<&Environment> = all .iter() @@ -518,6 +551,16 @@ fn environment_secondary(environment: &Environment, all: &[Environment]) -> Stri if duplicates.is_empty() { return String::new(); } + if duplicates + .iter() + .any(|other| other.address == environment.address) + { + return if environment.display_url.is_empty() { + environment.id.clone() + } else { + environment.display_url.clone() + }; + } environment.address.clone() } @@ -693,6 +736,7 @@ pub fn search_hits(query: &str) -> Vec<&'static str> { pub fn scope_data( primary_label: &str, primary_machine: MachineKind, + primary_url: &str, primary: &[crate::projects::ProjectEntry], others: &[crate::projects::EnvironmentProjects], ) -> ScopeData { @@ -701,7 +745,8 @@ pub fn scope_data( data.environments.push(Environment { id: PRIMARY.to_owned(), label: primary_label.to_owned(), - address: PRIMARY.to_owned(), + address: environment_address(primary_url, PRIMARY), + display_url: primary_url.to_owned(), machine: primary_machine, connected: true, writable: true, @@ -738,9 +783,10 @@ pub fn scope_data( data.environments.push(Environment { id: other.id.clone(), label: other.label.clone(), - address: other.id.clone(), + address: environment_address(&other.url, &other.id), + display_url: other.url.clone(), machine: other.machine, - connected: !other.projects.is_empty(), + connected: other.connected, writable: false, }); } @@ -756,6 +802,7 @@ mod tests { id: id.to_owned(), label: label.to_owned(), address: format!("{id}.example:3773"), + display_url: format!("https://{id}.example:3773"), machine: MachineKind::Laptop, connected, writable, @@ -1078,6 +1125,8 @@ mod tests { let other = crate::projects::EnvironmentProjects { id: "env-2".to_owned(), label: "Example Mac".to_owned(), + url: "http://mac.example.test:13774/".to_owned(), + connected: false, machine: MachineKind::Laptop, projects: vec![ entry("b", false, Some("repo")), @@ -1087,6 +1136,7 @@ mod tests { let data = scope_data( "Example Server", MachineKind::Server, + "http://server.example.test:13773", &[ entry("a", false, Some("repo")), entry("scratch", true, None), @@ -1095,6 +1145,16 @@ mod tests { ); assert_eq!(data.environments.len(), 2); assert!(data.environments[0].writable && !data.environments[1].writable); + assert_eq!(data.environments[0].address, "server.example.test:13773"); + assert_eq!(data.environments[1].address, "mac.example.test:13774"); + // The flag comes from the connection, not from whether projects arrived. + assert!(data.environments[0].connected && !data.environments[1].connected); + assert_eq!(environment_address("", "env-9"), "env-9"); + assert_eq!(environment_address("not a url", "env-9"), "not a url"); + assert_eq!( + environment_address("https://Example.test:443/p", "e"), + "example.test" + ); let names: Vec<_> = data.projects.iter().map(|p| p.key.as_str()).collect(); assert_eq!(names, ["repo", "only-b"]); assert_eq!(data.projects[0].environments, ["primary", "env-2"]); diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 4cff0e65c30f..e396bd872226 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -500,7 +500,13 @@ impl MainView { ), _ => return, }; - let data = crate::settings_labs::scope_data(&label, machine, &entries, &self.environments); + let data = crate::settings_labs::scope_data( + &label, + machine, + &crate::config::primary_url(), + &entries, + &self.environments, + ); let flags = self.providers.as_ref().map(|c| c.labs_flags()); let devices = self.providers.as_ref().map(|c| { ( From adb9ff64d7506026ac08ad962e06f512392d608d Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 9 Nov 2026 03:46:59 -0500 Subject: [PATCH 67/82] feat(desktop-gpui): appearance settings tab, composer workspace and run-on menus Adds the Appearance tab (color scheme, themes, interface, typography with font previews), ports the composer Workspace menu and Run on selector onto a shared menu style, and shows request failures by their error message like the web instead of raw JSON. --- apps/desktop-gpui/src/assets.rs | 11 +- apps/desktop-gpui/src/main.rs | 6 + apps/desktop-gpui/src/projects.rs | 1 + apps/desktop-gpui/src/rpc.rs | 50 +- apps/desktop-gpui/src/send.rs | 36 + apps/desktop-gpui/src/settings.rs | 3 + apps/desktop-gpui/src/settings_appearance.rs | 901 ++++++++++++++ apps/desktop-gpui/src/settings_typography.rs | 392 ++++++ apps/desktop-gpui/src/shell.rs | 1 + apps/desktop-gpui/src/status.rs | 2 + apps/desktop-gpui/src/strip.rs | 3 - apps/desktop-gpui/src/ui.rs | 382 ++++-- apps/desktop-gpui/src/ui/composer_menu.rs | 223 ++++ apps/desktop-gpui/src/ui/run_on_menu.rs | 131 ++ apps/desktop-gpui/src/ui/settings.rs | 49 +- .../src/ui/settings/appearance_panel.rs | 1039 ++++++++++++++++ .../src/ui/settings/appearance_themes.rs | 346 ++++++ .../src/ui/settings/appearance_typography.rs | 1086 +++++++++++++++++ .../src/ui/settings/components.rs | 339 ++++- .../src/ui/settings/scope_pickers.rs | 158 ++- apps/desktop-gpui/src/ui/settings/tokens.rs | 7 +- apps/desktop-gpui/src/ui/workspace_menu.rs | 126 ++ apps/desktop-gpui/src/workspace.rs | 281 +++++ 23 files changed, 5374 insertions(+), 199 deletions(-) create mode 100644 apps/desktop-gpui/src/settings_appearance.rs create mode 100644 apps/desktop-gpui/src/settings_typography.rs create mode 100644 apps/desktop-gpui/src/ui/composer_menu.rs create mode 100644 apps/desktop-gpui/src/ui/run_on_menu.rs create mode 100644 apps/desktop-gpui/src/ui/settings/appearance_panel.rs create mode 100644 apps/desktop-gpui/src/ui/settings/appearance_themes.rs create mode 100644 apps/desktop-gpui/src/ui/settings/appearance_typography.rs create mode 100644 apps/desktop-gpui/src/ui/workspace_menu.rs create mode 100644 apps/desktop-gpui/src/workspace.rs diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index 9c47255b3d56..a9fa362e68da 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -7,6 +7,7 @@ gpui_kit_assets::icon_assets!( ExtraIcons, [ Paperclip, + Box, Pin, PinOff, Server, @@ -22,6 +23,7 @@ gpui_kit_assets::icon_assets!( Folder, FolderGit, FolderGit2, + RotateCcwClock, GitBranch, ShieldOff, Lock, @@ -92,7 +94,10 @@ gpui_kit_assets::icon_assets!( PanelsTopLeft, Undo2, Apple, - Ellipsis + Ellipsis, + Sun, + Moon, + Paintbrush ] ); @@ -358,6 +363,9 @@ mod tests { IconName::Paperclip, IconName::Settings2, IconName::Palette, + IconName::Sun, + IconName::Moon, + IconName::Paintbrush, IconName::MessageSquareText, IconName::MessagesSquare, IconName::Keyboard, @@ -401,6 +409,7 @@ mod tests { IconName::Folder, IconName::FolderGit, IconName::FolderGit2, + IconName::RotateCcwClock, IconName::GitBranch, IconName::ShieldOff, IconName::Lock, diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 242056a17e1b..24af13f35dc8 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -22,9 +22,11 @@ mod rpc; mod send; mod server; mod settings; +mod settings_appearance; mod settings_devices; mod settings_diffs; mod settings_labs; +mod settings_typography; mod shell; mod slow_requests; mod status; @@ -43,6 +45,7 @@ mod usage_data; mod usage_limits; mod usage_prices; mod wire; +mod workspace; use assets::AppAssets; use config::{Attach, Config}; @@ -121,6 +124,9 @@ fn main() { KeyBinding::new("cmd-u", gpui::NoAction, Some("Terminal")), KeyBinding::new("escape", ui::DismissSidebar, Some(ui::OVERLAY_CONTEXT)), KeyBinding::new("shift-cmd-a", ui::OpenPermissions, None), + // `composer.workspace` and `composer.previousWorktree`. + KeyBinding::new("shift-cmd-x", ui::OpenWorkspace, None), + KeyBinding::new("shift-cmd-l", ui::UsePreviousWorktree, None), KeyBinding::new("shift-cmd-b", ui::ToggleDetails, None), KeyBinding::new("cmd-j", ui::ToggleTerminal, None), KeyBinding::new("alt-cmd-b", ui::ToggleRightPanel, None), diff --git a/apps/desktop-gpui/src/projects.rs b/apps/desktop-gpui/src/projects.rs index 2d3c7cf6b671..b241dd5982d2 100644 --- a/apps/desktop-gpui/src/projects.rs +++ b/apps/desktop-gpui/src/projects.rs @@ -689,6 +689,7 @@ mod tests { last_error: None, worktree_path: None, has_run: false, + updated_at_ms: None, } } diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs index 1395de7701d1..e56850cee651 100644 --- a/apps/desktop-gpui/src/rpc.rs +++ b/apps/desktop-gpui/src/rpc.rs @@ -444,20 +444,38 @@ impl Session { } } +// Mirrors the web's `Cause.squash(cause).message`: the first failure wins, then the first defect. +// Contract errors compute `message` from a getter that is not on the wire, so fall back to the +// fields those getters usually return. fn describe_cause(cause: &[CauseFrame]) -> String { - let parts: Vec = cause + let squashed = cause .iter() - .map(|entry| match entry { - CauseFrame::Fail { error } => format!("failed: {error}"), - CauseFrame::Die { defect } => format!("defect: {defect}"), - CauseFrame::Interrupt => "interrupted".to_owned(), + .find_map(|entry| match entry { + CauseFrame::Fail { error } => Some(error), + _ => None, }) - .collect(); - if parts.is_empty() { - "request failed".to_owned() - } else { - parts.join("; ") + .or_else(|| { + cause.iter().find_map(|entry| match entry { + CauseFrame::Die { defect } => Some(defect), + _ => None, + }) + }); + match squashed { + Some(error) => error_message(error), + None if cause.is_empty() => "request failed".to_owned(), + None => "interrupted".to_owned(), + } +} + +fn error_message(error: &Value) -> String { + if let Some(text) = error.as_str() { + return text.to_owned(); } + ["message", "reason", "detail", "_tag"] + .iter() + .find_map(|key| error.get(key).and_then(Value::as_str)) + .filter(|text| !text.is_empty()) + .map_or_else(|| error.to_string(), str::to_owned) } #[cfg(test)] @@ -548,7 +566,17 @@ mod tests { failed.events, vec![Event::Failed { request_id: "2".into(), - message: r#"failed: {"_tag":"Nope"}; interrupted"#.into() + message: "Nope".into() + }] + ); + let reasoned = session.react( + r#"{"_tag":"Exit","requestId":"3","exit":{"_tag":"Failure","cause":[{"_tag":"Die","defect":"boom"},{"_tag":"Fail","error":{"_tag":"DeviceEasAccountError","reason":"No Expo credential"}}]}}"#, + ); + assert_eq!( + reasoned.events, + vec![Event::Failed { + request_id: "3".into(), + message: "No Expo credential".into() }] ); assert!(matches!( diff --git a/apps/desktop-gpui/src/send.rs b/apps/desktop-gpui/src/send.rs index 6147b03fdcdf..7a4ff7ae2ed2 100644 --- a/apps/desktop-gpui/src/send.rs +++ b/apps/desktop-gpui/src/send.rs @@ -85,6 +85,11 @@ impl Model<'_> { pub enum Workspace<'a> { /// The project's checkout, on `branch` when it is known. Root { branch: Option<&'a str> }, + /// A worktree the draft already has, the Previous worktree row's, on `branch` when it has one. + Existing { + worktree_path: &'a str, + branch: Option<&'a str>, + }, /// A new worktree started from `base`. Worktree { base: &'a str, @@ -119,6 +124,16 @@ impl<'a> Workspace<'a> { Self::Root { branch: Some(branch), } => json!({"type": "root", "branch": branch}), + Self::Existing { + worktree_path, + branch: None, + } => json!({"type": "existing_worktree", "worktreePath": worktree_path}), + Self::Existing { + worktree_path, + branch: Some(branch), + } => { + json!({"type": "existing_worktree", "worktreePath": worktree_path, "branch": branch}) + } Self::Worktree { base, start_from_origin: true, @@ -495,6 +510,27 @@ mod tests { ); } + #[test] + fn a_reused_worktree_launches_in_place() { + let strategy = |branch| { + let existing = Workspace::Existing { + worktree_path: "/work/tree", + branch, + }; + let payload = plan(&composer("go", Ok(existing)), ids()).unwrap().payload; + assert_matches_contract("orchestration.launchThread", &payload); + payload["workspaceStrategy"].clone() + }; + assert_eq!( + strategy(Some("feature")), + json!({"type": "existing_worktree", "worktreePath": "/work/tree", "branch": "feature"}) + ); + assert_eq!( + strategy(None), + json!({"type": "existing_worktree", "worktreePath": "/work/tree"}) + ); + } + #[test] fn worktree_mode_without_a_base_ref_does_not_send() { let nameless = Branch { diff --git a/apps/desktop-gpui/src/settings.rs b/apps/desktop-gpui/src/settings.rs index af50bee79306..f612c60ea22b 100644 --- a/apps/desktop-gpui/src/settings.rs +++ b/apps/desktop-gpui/src/settings.rs @@ -661,7 +661,10 @@ pub fn searchable() -> &'static [SearchItem] { .iter() .chain(crate::settings_labs::LABS_ITEMS) .chain(crate::settings_diffs::DIFFS_ITEMS) + .chain(crate::settings_appearance::APPEARANCE_ITEMS) + .chain(crate::settings_typography::TYPOGRAPHY_ITEMS) .chain(CHAT_ITEMS) + .chain(crate::settings_typography::TYPOGRAPHY_ITEMS) .chain(crate::settings_devices::DEVICE_ITEMS) .copied() .collect() diff --git a/apps/desktop-gpui/src/settings_appearance.rs b/apps/desktop-gpui/src/settings_appearance.rs new file mode 100644 index 000000000000..9b312ad08e44 --- /dev/null +++ b/apps/desktop-gpui/src/settings_appearance.rs @@ -0,0 +1,901 @@ +//! The Appearance tab's Color scheme, Themes and Interface sections. The Interface keys are client +//! settings (`ClientSettingsSchema`, contracts/src/settings.ts:389-583) kept in +//! `client-settings.json` beside the other tabs' keys. The web keeps the color mode and the theme +//! halves in `localStorage` (`useTheme.ts`), so the desktop keeps them in `appearance-theme.json` +//! next to that file. + +use crate::settings::{SearchItem, Section}; +use serde_json::{Map, Value, json}; +use std::collections::BTreeMap; +use std::path::PathBuf; + +/// `chatWidth` (settings.ts:393), labels from `CHAT_WIDTH_LABELS`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ChatWidth { + Comfortable, + Wide, + Full, +} + +impl ChatWidth { + pub const ALL: &'static [Self] = &[Self::Comfortable, Self::Wide, Self::Full]; + + pub fn wire(self) -> &'static str { + match self { + Self::Comfortable => "comfortable", + Self::Wide => "wide", + Self::Full => "full", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Comfortable => "Comfortable", + Self::Wide => "Wide", + Self::Full => "Full", + } + } + + pub fn menu_label(self) -> &'static str { + match self { + Self::Comfortable => "Comfortable (default)", + other => other.label(), + } + } + + pub fn from_wire(value: &str) -> Option { + Self::ALL.iter().copied().find(|v| v.wire() == value) + } +} + +/// `effortDisplay` (settings.ts:402). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EffortDisplay { + Label, + Bars, +} + +impl EffortDisplay { + pub const ALL: &'static [Self] = &[Self::Label, Self::Bars]; + + pub fn wire(self) -> &'static str { + match self { + Self::Label => "label", + Self::Bars => "bars", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Label => "Word", + Self::Bars => "Bars", + } + } + + pub fn menu_label(self) -> &'static str { + match self { + Self::Label => "Word (default)", + Self::Bars => "Bars", + } + } + + pub fn from_wire(value: &str) -> Option { + Self::ALL.iter().copied().find(|v| v.wire() == value) + } +} + +/// `environmentIdentificationMode` (settings.ts:446). The desktop shell always runs the dev +/// stage, so the first option is the "Dev artwork" one. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EnvironmentIdentification { + Artwork, + NightlyArtwork, + DevArtwork, + Pill, + None, +} + +impl EnvironmentIdentification { + /// The options a dev stage offers, in menu order. + pub const ALL: &'static [Self] = &[Self::Artwork, Self::NightlyArtwork, Self::Pill, Self::None]; + + pub fn wire(self) -> &'static str { + match self { + Self::Artwork => "artwork", + Self::NightlyArtwork => "nightly-artwork", + Self::DevArtwork => "dev-artwork", + Self::Pill => "pill", + Self::None => "none", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Artwork => "Dev artwork", + Self::NightlyArtwork => "Nightly artwork", + Self::DevArtwork => "Dev artwork", + Self::Pill => "Version pill", + Self::None => "None", + } + } + + pub fn from_wire(value: &str) -> Option { + [ + Self::Artwork, + Self::NightlyArtwork, + Self::DevArtwork, + Self::Pill, + Self::None, + ] + .into_iter() + .find(|v| v.wire() == value) + } + + /// Where the value sits in `ALL`; the legacy `dev-artwork` reads as the default. + pub fn index(self) -> usize { + Self::ALL.iter().position(|v| *v == self).unwrap_or(0) + } +} + +/// One entry of the "Thread details" menu: its sparse-map key, label, and default. +#[derive(Clone, Copy, Debug)] +pub struct ThreadItem { + pub key: &'static str, + pub label: &'static str, + /// `usage` and `context` follow the retired `threadDetailsUsageEnabled` flag. + pub follows_usage_flag: bool, +} + +/// `THREAD_DETAILS_ITEMS`, in menu order. +pub const THREAD_ITEMS: [ThreadItem; 12] = [ + ThreadItem::on("environment", "Environment and worktree"), + ThreadItem::on("openIn", "Open in editor"), + ThreadItem::on("scripts", "Project scripts"), + ThreadItem::on("branch", "Branch and pull request"), + ThreadItem::on("gitActions", "Git actions"), + ThreadItem::on("changes", "Changes"), + ThreadItem::on("servers", "Servers"), + ThreadItem::on("automations", "Automations"), + ThreadItem::on("lineage", "Lineage"), + ThreadItem::on("sideChats", "Side chats"), + ThreadItem::usage("usage", "Usage limits"), + ThreadItem::usage("context", "Context window"), +]; + +impl ThreadItem { + const fn on(key: &'static str, label: &'static str) -> Self { + Self { + key, + label, + follows_usage_flag: false, + } + } + + const fn usage(key: &'static str, label: &'static str) -> Self { + Self { + key, + label, + follows_usage_flag: true, + } + } +} + +/// The Interface section's settings, with `DEFAULT_CLIENT_SETTINGS` defaults. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AppearanceSettings { + /// `appearanceContrast`, 50..=200 step 5, default 100. + pub contrast: u32, + /// `glassOpacity`, 40..=100 step 5, default 80. + pub glass_opacity: u32, + /// `panelAnimationDurationMs`, 0..=400 step 25, default 0. + pub panel_animation_ms: u32, + pub chat_width: ChatWidth, + pub effort_display: EffortDisplay, + pub environment_identification: EnvironmentIdentification, + /// `mathRenderingEnabled`, default off. + pub math_rendering: bool, + /// `persistComposerContextStrip`, default off. + pub composer_context: bool, + /// `threadDetailsItems`: only the choices that differ from their default. + pub thread_items: BTreeMap, + /// `threadDetailsUsageEnabled`, default off. + pub thread_usage_flag: bool, +} + +pub const CONTRAST: Slide = Slide::new(50, 200, 5, 100); +pub const GLASS: Slide = Slide::new(40, 100, 5, 80); +pub const PANEL_MS: Slide = Slide::new(0, 400, 25, 0); + +/// Bounds of a stepped integer slider. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Slide { + pub min: u32, + pub max: u32, + pub step: u32, + pub default: u32, +} + +impl Slide { + const fn new(min: u32, max: u32, step: u32, default: u32) -> Self { + Self { + min, + max, + step, + default, + } + } + + /// Accepts only a value the schema accepts: in range and on a step. + pub fn valid(self, value: u32) -> bool { + (self.min..=self.max).contains(&value) && (value - self.min).is_multiple_of(self.step) + } +} + +impl Default for AppearanceSettings { + fn default() -> Self { + Self { + contrast: CONTRAST.default, + glass_opacity: GLASS.default, + panel_animation_ms: PANEL_MS.default, + chat_width: ChatWidth::Comfortable, + effort_display: EffortDisplay::Label, + environment_identification: EnvironmentIdentification::Artwork, + math_rendering: false, + composer_context: false, + thread_items: BTreeMap::new(), + thread_usage_flag: false, + } + } +} + +/// The document is bare or wrapped as `{"settings": {...}}` (`decodeClientSettingsJson`). +fn fields(value: &Value) -> Option<&Map> { + let object = value.as_object()?; + match object.get("settings") { + Some(Value::Object(inner)) => Some(inner), + _ => Some(object), + } +} + +impl AppearanceSettings { + pub fn parse(text: &str) -> Option { + let value: Value = serde_json::from_str(text).ok()?; + let map = fields(&value)?; + let d = Self::default(); + let flag = + |key: &str, fallback: bool| map.get(key).and_then(Value::as_bool).unwrap_or(fallback); + let choice = |key: &str| map.get(key).and_then(Value::as_str); + let slide = |key: &str, range: Slide| { + map.get(key) + .and_then(Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .filter(|v| range.valid(*v)) + .unwrap_or(range.default) + }; + let thread_items = map + .get("threadDetailsItems") + .and_then(Value::as_object) + .map(|items| { + THREAD_ITEMS + .iter() + .filter_map(|item| { + let on = items.get(item.key)?.as_bool()?; + Some((item.key.to_owned(), on)) + }) + .collect() + }) + .unwrap_or_default(); + Some(Self { + contrast: slide("appearanceContrast", CONTRAST), + glass_opacity: slide("glassOpacity", GLASS), + panel_animation_ms: slide("panelAnimationDurationMs", PANEL_MS), + chat_width: choice("chatWidth") + .and_then(ChatWidth::from_wire) + .unwrap_or(d.chat_width), + effort_display: choice("effortDisplay") + .and_then(EffortDisplay::from_wire) + .unwrap_or(d.effort_display), + environment_identification: choice("environmentIdentificationMode") + .and_then(EnvironmentIdentification::from_wire) + .unwrap_or(d.environment_identification), + math_rendering: flag("mathRenderingEnabled", d.math_rendering), + composer_context: flag("persistComposerContextStrip", d.composer_context), + thread_items, + thread_usage_flag: flag("threadDetailsUsageEnabled", d.thread_usage_flag), + }) + } + + pub fn entries(&self) -> [(&'static str, Value); 10] { + let items: Map = self + .thread_items + .iter() + .map(|(key, on)| (key.clone(), json!(on))) + .collect(); + [ + ("appearanceContrast", json!(self.contrast)), + ("glassOpacity", json!(self.glass_opacity)), + ("panelAnimationDurationMs", json!(self.panel_animation_ms)), + ("chatWidth", json!(self.chat_width.wire())), + ("effortDisplay", json!(self.effort_display.wire())), + ( + "environmentIdentificationMode", + json!(self.environment_identification.wire()), + ), + ("mathRenderingEnabled", json!(self.math_rendering)), + ("persistComposerContextStrip", json!(self.composer_context)), + ("threadDetailsItems", Value::Object(items)), + ("threadDetailsUsageEnabled", json!(self.thread_usage_flag)), + ] + } + + /// Writes these keys into `existing`, keeping every key this tab does not own. + pub fn to_json(&self, existing: Option<&str>) -> String { + let mut document = existing + .and_then(|text| serde_json::from_str::(text).ok()) + .filter(Value::is_object) + .unwrap_or_else(|| json!({})); + let target = match document.get_mut("settings") { + Some(inner) if inner.is_object() => inner, + _ => &mut document, + }; + if let Some(map) = target.as_object_mut() { + for (key, value) in self.entries() { + map.insert(key.to_owned(), value); + } + } + document.to_string() + } + + pub fn load() -> Self { + crate::settings::DeviceSettings::path() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|text| Self::parse(&text)) + .unwrap_or_default() + } + + /// Re-reads the file first, so keys the other tabs saved in the meantime survive. + pub fn save(&self) { + if let Some(path) = crate::settings::DeviceSettings::path() { + let existing = std::fs::read_to_string(&path).ok(); + let _ = std::fs::write(path, self.to_json(existing.as_deref())); + } + } + + /// Whether a thread-details item is shown (`resolveThreadDetailsItems`). + pub fn item_on(&self, item: &ThreadItem) -> bool { + self.thread_items + .get(item.key) + .copied() + .unwrap_or(!item.follows_usage_flag || self.thread_usage_flag) + } + + /// `{enabled} of 12 on`. + pub fn items_on(&self) -> usize { + THREAD_ITEMS + .iter() + .filter(|item| self.item_on(item)) + .count() + } + + /// Stores only a choice that differs from the default, as the web does. + pub fn set_item(&mut self, item: &ThreadItem, on: bool) { + let default = !item.follows_usage_flag; + if on == default { + self.thread_items.remove(item.key); + } else { + self.thread_items.insert(item.key.to_owned(), on); + } + } + + /// Whether the Thread details row differs from its default. + pub fn items_changed(&self) -> bool { + !self.thread_items.is_empty() || self.thread_usage_flag + } + + pub fn reset_items(&mut self) { + self.thread_items.clear(); + self.thread_usage_flag = false; + } +} + +/// `Math.round`-free stepping: the value under `ratio` (0 to 1) of a slider, snapped to a step. +pub fn slide_value(range: Slide, ratio: f32) -> u32 { + let ratio = ratio.clamp(0., 1.); + let steps = ((range.max - range.min) / range.step) as f32; + range.min + (ratio * steps).round() as u32 * range.step +} + +/// `appearanceMode` of the web theme store. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ColorMode { + System, + Light, + Dark, +} + +impl ColorMode { + pub const ALL: [Self; 3] = [Self::System, Self::Light, Self::Dark]; + + pub fn wire(self) -> &'static str { + match self { + Self::System => "system", + Self::Light => "light", + Self::Dark => "dark", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::System => "System", + Self::Light => "Light", + Self::Dark => "Dark", + } + } + + pub fn from_wire(value: &str) -> Option { + Self::ALL.iter().copied().find(|v| v.wire() == value) + } +} + +/// The built-in themes, in the web's card order (T3 Code first, then `BUILT_IN_THEMES`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ThemeId { + Default, + T3Chat, + Grove, + Ocean, + Ember, + Iris, +} + +/// A palette color: an sRGB hex, or the OKLCH triple the web themes are written in. +#[derive(Clone, Copy, Debug)] +pub enum Paint { + Hex(u32), + Oklch(f32, f32, f32), +} + +impl Paint { + /// sRGB, each channel 0 to 1. + pub fn rgb(self) -> [f32; 3] { + match self { + Self::Hex(hex) => [ + ((hex >> 16) & 0xff) as f32 / 255., + ((hex >> 8) & 0xff) as f32 / 255., + (hex & 0xff) as f32 / 255., + ], + Self::Oklch(l, c, h) => oklch_to_srgb(l, c, h), + } + } + + #[cfg(test)] + fn hex(self) -> u32 { + let [r, g, b] = self.rgb(); + let byte = |v: f32| (v.clamp(0., 1.) * 255.).round() as u32; + byte(r) << 16 | byte(g) << 8 | byte(b) + } +} + +/// OKLCH to gamma-encoded sRGB, clipped to the gamut (CSS Color 4 without gamut mapping). +pub fn oklch_to_srgb(l: f32, c: f32, h: f32) -> [f32; 3] { + let (sin, cos) = h.to_radians().sin_cos(); + let (a, b) = (c * cos, c * sin); + let lms = [ + l + 0.396_337_78 * a + 0.215_803_76 * b, + l - 0.105_561_346 * a - 0.063_854_17 * b, + l - 0.089_484_18 * a - 1.291_485_5 * b, + ] + .map(|v| v * v * v); + let linear = [ + 4.076_741_7 * lms[0] - 3.307_711_6 * lms[1] + 0.230_969_93 * lms[2], + -1.268_438 * lms[0] + 2.609_757_4 * lms[1] - 0.341_319_4 * lms[2], + -0.004_196_086_3 * lms[0] - 0.703_418_6 * lms[1] + 1.707_614_7 * lms[2], + ]; + linear.map(|v| { + let v = v.clamp(0., 1.); + if v <= 0.003_130_8 { + 12.92 * v + } else { + 1.055 * v.powf(1. / 2.4) - 0.055 + } + }) +} + +/// The three roles a theme orb paints from (`THEME_PREVIEW_RENDER_SPECS`): the canvas it tints, +/// the accent that fills it, and the message action that glows opposite. +#[derive(Clone, Copy, Debug)] +pub struct OrbPalette { + pub canvas: Paint, + pub accent: Paint, + pub action: Paint, +} + +const fn hex_orb(canvas: u32, accent: u32, action: u32) -> OrbPalette { + OrbPalette { + canvas: Paint::Hex(canvas), + accent: Paint::Hex(accent), + action: Paint::Hex(action), + } +} + +const fn orb(canvas: [f32; 3], accent: [f32; 3], action: [f32; 3]) -> OrbPalette { + OrbPalette { + canvas: Paint::Oklch(canvas[0], canvas[1], canvas[2]), + accent: Paint::Oklch(accent[0], accent[1], accent[2]), + action: Paint::Oklch(action[0], action[1], action[2]), + } +} + +impl ThemeId { + pub const ALL: [Self; 6] = [ + Self::Default, + Self::T3Chat, + Self::Grove, + Self::Ocean, + Self::Ember, + Self::Iris, + ]; + + pub fn wire(self) -> &'static str { + match self { + Self::Default => "default", + Self::T3Chat => "t3-chat", + Self::Grove => "grove", + Self::Ocean => "ocean", + Self::Ember => "ember", + Self::Iris => "iris", + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Default => "T3 Code", + Self::T3Chat => "T3 Chat", + Self::Grove => "Grove", + Self::Ocean => "Ocean", + Self::Ember => "Ember", + Self::Iris => "Iris", + } + } + + pub fn from_wire(value: &str) -> Option { + Self::ALL.iter().copied().find(|v| v.wire() == value) + } + + /// The orb colors for one half (themePalettes.ts). + pub fn orb(self, dark: bool) -> OrbPalette { + match (self, dark) { + (Self::Default, false) => hex_orb(0xfcfcfc, 0xf4f4f5, 0x4f46e5), + (Self::Default, true) => hex_orb(0x0a0a0a, 0x1c1c1f, 0x8b9cff), + (Self::T3Chat, false) => orb( + [0.982446, 0.010114, 325.653], + [0.591646, 0.217985, 0.584], + [0.591646, 0.217985, 0.584], + ), + (Self::T3Chat, true) => orb( + [0.22813, 0.020366, 307.469], + [0.460685, 0.185347, 4.099], + [0.460685, 0.185347, 4.099], + ), + (Self::Grove, false) => orb( + [0.972369, 0.005497, 157.15], + [0.523295, 0.112292, 158.089], + [0.535028, 0.106403, 77.549], + ), + (Self::Grove, true) => orb( + [0.260865, 0.02152, 162.75], + [0.796228, 0.133058, 157.319], + [0.791603, 0.129713, 83.299], + ), + (Self::Ocean, false) => orb( + [0.974199, 0.002856, 241.597], + [0.536684, 0.120219, 247.01], + [0.493961, 0.08175, 201.584], + ), + (Self::Ocean, true) => orb( + [0.242641, 0.024125, 250.573], + [0.758933, 0.105833, 241.548], + [0.793363, 0.105022, 199.893], + ), + (Self::Ember, false) => orb( + [0.976527, 0.002685, 60.725], + [0.552831, 0.129438, 44.656], + [0.516323, 0.161628, 24.82], + ), + (Self::Ember, true) => orb( + [0.245899, 0.019144, 42.044], + [0.762174, 0.124117, 52.082], + [0.747955, 0.135578, 29.432], + ), + (Self::Iris, false) => orb( + [0.976531, 0.003855, 303.226], + [0.525348, 0.15373, 294.176], + [0.516084, 0.185229, 340.776], + ), + (Self::Iris, true) => orb( + [0.225975, 0.031062, 293.741], + [0.671712, 0.169136, 293.929], + [0.789904, 0.130063, 337.621], + ), + } + } +} + +/// The color mode and the two theme halves; light and dark each name their own theme. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ThemeChoice { + pub mode: ColorMode, + pub light: ThemeId, + pub dark: ThemeId, +} + +impl Default for ThemeChoice { + fn default() -> Self { + Self { + mode: ColorMode::System, + light: ThemeId::Default, + dark: ThemeId::Default, + } + } +} + +impl ThemeChoice { + const FILE: &'static str = "appearance-theme.json"; + + /// The halves a theme owns, light first (`pickedModesFor`). + pub fn picked(self, id: ThemeId) -> (bool, bool) { + (self.light == id, self.dark == id) + } + + /// A card sets both halves (`persistTheme`). + pub fn pick_both(&mut self, id: ThemeId) { + self.light = id; + self.dark = id; + } + + pub fn pick_half(&mut self, id: ThemeId, dark: bool) { + if dark { + self.dark = id; + } else { + self.light = id; + } + } + + pub fn parse(text: &str) -> Option { + let value: Value = serde_json::from_str(text).ok()?; + let object = value.as_object()?; + let d = Self::default(); + let half = |key: &str, fallback: ThemeId| { + object + .get("themeHalves") + .and_then(|halves| halves.get(key)) + .and_then(Value::as_str) + .and_then(ThemeId::from_wire) + .unwrap_or(fallback) + }; + Some(Self { + mode: object + .get("appearanceMode") + .and_then(Value::as_str) + .and_then(ColorMode::from_wire) + .unwrap_or(d.mode), + light: half("light", d.light), + dark: half("dark", d.dark), + }) + } + + pub fn to_json(self) -> String { + json!({ + "appearanceMode": self.mode.wire(), + "themeHalves": { "light": self.light.wire(), "dark": self.dark.wire() }, + }) + .to_string() + } + + fn path() -> Option { + crate::settings::DeviceSettings::path().map(|path| path.with_file_name(Self::FILE)) + } + + pub fn load() -> Self { + Self::path() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|text| Self::parse(&text)) + .unwrap_or_default() + } + + pub fn save(self) { + if let Some(path) = Self::path() { + let _ = std::fs::write(path, self.to_json()); + } + } +} + +/// `color-mix(in srgb, from, to weight)`: `weight` of `from` over the rest of `to`. +pub fn mix(from: [f32; 3], to: [f32; 3], weight: f32) -> [f32; 3] { + std::array::from_fn(|i| from[i] * weight + to[i] * (1. - weight)) +} + +const fn item(id: &'static str, title: &'static str, terms: &'static str) -> SearchItem { + SearchItem { + id, + title, + section: Section::Appearance, + terms, + desktop_only: false, + } +} + +/// The Appearance `searchableSetting` entries for these sections (settingsSearch.ts), in the +/// web's order. Typography's come from its own module. +pub const APPEARANCE_ITEMS: &[SearchItem] = &[ + item( + "color-scheme", + "Color scheme", + "appearance light dark system mode", + ), + item("theme", "Themes", "appearance colors palette custom import"), + item( + "setting-appearance-contrast", + "Contrast", + "colors borders interface", + ), + item( + "setting-glass-opacity", + "Glass opacity", + "transparent transparency solid menus dialogs composer", + ), + item( + "chat-width", + "Chat width", + "wide full width column layout messages composer monitor", + ), + item( + "thread-details-items", + "Thread details", + "customize show hide panel card environment editor scripts branch pull request git changes servers automations lineage usage limits 5 hour weekly quota tokens context window", + ), + item( + "effort-display", + "Effort display", + "reasoning effort thinking bars graph word label level composer", + ), + item( + "math-rendering", + "Render math", + "latex tex katex equations formulas markdown", + ), + item("panel-animations", "Panel animations", ""), + item( + "environment-identification", + "Environment identification", + "dev nightly latest channel artwork pill label hide none", + ), + item("composer-context", "Composer context", ""), +]; + +/// The search ids that live under the Advanced disclosure. +pub fn is_advanced(id: &str) -> bool { + matches!( + id, + "panel-animations" | "environment-identification" | "composer-context" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::search; + + #[test] + fn defaults_match_the_contract() { + let d = AppearanceSettings::default(); + assert_eq!( + (d.contrast, d.glass_opacity, d.panel_animation_ms), + (100, 80, 0) + ); + assert_eq!(d.items_on(), 10); + assert!(!d.items_changed()); + } + + #[test] + fn parse_falls_back_per_key_and_rejects_off_step_numbers() { + let text = r#"{"settings":{"appearanceContrast":120,"glassOpacity":42,"chatWidth":"full","effortDisplay":"nope","panelAnimationDurationMs":400,"environmentIdentificationMode":"pill","mathRenderingEnabled":true}}"#; + let s = AppearanceSettings::parse(text).unwrap(); + assert_eq!(s.contrast, 120); + assert_eq!(s.glass_opacity, 80); + assert_eq!(s.chat_width, ChatWidth::Full); + assert_eq!(s.effort_display, EffortDisplay::Label); + assert_eq!(s.panel_animation_ms, 400); + assert_eq!( + s.environment_identification, + EnvironmentIdentification::Pill + ); + assert!(s.math_rendering); + assert!(AppearanceSettings::parse("[]").is_none()); + } + + #[test] + fn thread_items_store_only_non_defaults_and_follow_the_usage_flag() { + let mut s = AppearanceSettings::default(); + let usage = &THREAD_ITEMS[10]; + let changes = &THREAD_ITEMS[5]; + s.set_item(usage, true); + s.set_item(changes, false); + assert_eq!(s.thread_items.len(), 2); + assert_eq!(s.items_on(), 10); + s.set_item(usage, false); + s.set_item(changes, true); + assert!(s.thread_items.is_empty()); + + s.thread_usage_flag = true; + assert!(s.item_on(usage) && s.item_on(&THREAD_ITEMS[11])); + assert_eq!(s.items_on(), 12); + s.reset_items(); + assert_eq!(s.items_on(), 10); + } + + #[test] + fn save_keeps_foreign_keys_and_round_trips() { + let mut s = AppearanceSettings { + chat_width: ChatWidth::Wide, + ..AppearanceSettings::default() + }; + s.set_item(&THREAD_ITEMS[0], false); + let out = s.to_json(Some(r#"{"settings":{"timestampFormat":"24-hour"}}"#)); + let value: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(value["settings"]["timestampFormat"], "24-hour"); + assert_eq!( + value["settings"]["threadDetailsItems"]["environment"], + false + ); + assert_eq!(AppearanceSettings::parse(&out), Some(s)); + } + + #[test] + fn slider_snaps_to_steps() { + assert_eq!(slide_value(CONTRAST, 0.), 50); + assert_eq!(slide_value(CONTRAST, 1.), 200); + assert_eq!(slide_value(CONTRAST, 1. / 3.), 100); + assert_eq!(slide_value(PANEL_MS, 0.51), 200); + assert_eq!(slide_value(GLASS, 2.), 100); + } + + #[test] + fn oklch_matches_known_colors() { + assert_eq!(Paint::Oklch(1., 0., 0.).hex(), 0xffffff); + assert_eq!(Paint::Oklch(0., 0., 0.).hex(), 0x000000); + // oklch(0.628 0.2577 29.23) is sRGB red. + assert_eq!( + Paint::Oklch(0.627_955, 0.257_683, 29.233_89).hex(), + 0xff0000 + ); + assert_eq!(Paint::Hex(0x4f46e5).hex(), 0x4f46e5); + } + + #[test] + fn theme_choice_follows_the_web_semantics() { + let mut t = ThemeChoice::default(); + assert_eq!(t.picked(ThemeId::Default), (true, true)); + t.pick_both(ThemeId::Grove); + t.pick_half(ThemeId::Iris, true); + assert_eq!(t.picked(ThemeId::Grove), (true, false)); + assert_eq!(t.picked(ThemeId::Iris), (false, true)); + t.mode = ColorMode::Dark; + assert_eq!(ThemeChoice::parse(&t.to_json()), Some(t)); + assert_eq!(ThemeChoice::parse("{}"), Some(ThemeChoice::default())); + } + + #[test] + fn items_are_searchable() { + assert!( + APPEARANCE_ITEMS + .iter() + .all(|i| i.section == Section::Appearance) + ); + assert_eq!( + search("latex", APPEARANCE_ITEMS).first().map(|i| i.id), + Some("math-rendering") + ); + assert!(is_advanced("composer-context") && !is_advanced("chat-width")); + } +} diff --git a/apps/desktop-gpui/src/settings_typography.rs b/apps/desktop-gpui/src/settings_typography.rs new file mode 100644 index 000000000000..7bc281034444 --- /dev/null +++ b/apps/desktop-gpui/src/settings_typography.rs @@ -0,0 +1,392 @@ +//! The Appearance tab's Typography section: the font keys of the device settings +//! (`UnifiedSettings`, contracts/src/settings.ts:467-484, 576) kept in `client-settings.json` +//! beside the other tabs' keys. The web keeps these in the server settings and the "Separate +//! fonts" flag in `localStorage` (`t3code:typography-advanced`); the desktop keeps all of them in +//! the one device file. + +use crate::settings::{SearchItem, Section}; +use serde_json::{Map, Value, json}; +use std::path::PathBuf; + +/// `DEFAULT_*_FONT_SIZE` and their bounds (settings.ts:160-196). +pub const INTERFACE_SIZE: SizeRange = SizeRange::new(12, 20, 16); +pub const PROMPT_SIZE: SizeRange = SizeRange::new(12, 20, 14); +pub const CODE_SIZE: SizeRange = SizeRange::new(10, 18, 13); +pub const TERMINAL_SIZE: SizeRange = SizeRange::new(8, 20, 12); + +/// `FontFamilyPreference` is a string of at most 200 characters (settings.ts:294). +const MAX_FAMILY: usize = 200; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct SizeRange { + pub min: u32, + pub max: u32, + pub default: u32, +} + +impl SizeRange { + const fn new(min: u32, max: u32, default: u32) -> Self { + Self { min, max, default } + } + + pub fn contains(self, px: u32) -> bool { + (self.min..=self.max).contains(&px) + } + + /// The px list of the size select, `min` through `max`. + pub fn options(self) -> impl Iterator { + self.min..=self.max + } +} + +/// The four font rows of the section. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Slot { + Interface, + Prompt, + Code, + Terminal, +} + +impl Slot { + pub const ALL: [Self; 4] = [Self::Interface, Self::Prompt, Self::Code, Self::Terminal]; + + pub fn range(self) -> SizeRange { + match self { + Self::Interface => INTERFACE_SIZE, + Self::Prompt => PROMPT_SIZE, + Self::Code => CODE_SIZE, + Self::Terminal => TERMINAL_SIZE, + } + } + + /// The monospace rows take only fixed-width families (`requireMonospace`). + pub fn requires_monospace(self) -> bool { + matches!(self, Self::Code | Self::Terminal) + } + + fn family_key(self) -> &'static str { + match self { + Self::Interface => "fontFamilySans", + Self::Prompt => "fontFamilyComposer", + Self::Code => "fontFamilyCode", + Self::Terminal => "fontFamilyTerminal", + } + } + + fn size_key(self) -> &'static str { + match self { + Self::Interface => "fontSizeInterface", + Self::Prompt => "fontSizePrompt", + Self::Code => "fontSizeCode", + Self::Terminal => "fontSizeTerminal", + } + } + + fn index(self) -> usize { + Self::ALL.iter().position(|s| *s == self).unwrap_or(0) + } +} + +/// The Typography section's settings, with the contract's defaults. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TypographySettings { + /// `fontFamily{Sans,Composer,Code,Terminal}`, empty for the default. + pub families: [String; 4], + /// `fontSize{Interface,Prompt,Code,Terminal}`. + pub sizes: [u32; 4], + /// `fontSmoothing`, default on (settings.ts:484). + pub smoothing: bool, + /// `wordWrap`, default on (settings.ts:576). + pub word_wrap: bool, + /// "Separate fonts": the web's `TYPOGRAPHY_ADVANCED_STORAGE_KEY`, default off. + pub separate: bool, +} + +impl Default for TypographySettings { + fn default() -> Self { + Self { + families: Default::default(), + sizes: Slot::ALL.map(|slot| slot.range().default), + smoothing: true, + word_wrap: true, + separate: false, + } + } +} + +fn fields(value: &Value) -> Option<&Map> { + let object = value.as_object()?; + match object.get("settings") { + Some(Value::Object(inner)) => Some(inner), + _ => Some(object), + } +} + +impl TypographySettings { + pub fn family(&self, slot: Slot) -> &str { + &self.families[slot.index()] + } + + pub fn size(&self, slot: Slot) -> u32 { + self.sizes[slot.index()] + } + + pub fn set_family(&mut self, slot: Slot, family: &str) { + self.families[slot.index()] = family.chars().take(MAX_FAMILY).collect(); + } + + pub fn set_size(&mut self, slot: Slot, px: u32) { + if slot.range().contains(px) { + self.sizes[slot.index()] = px; + } + } + + /// A row's reset puts back its family and its size (TypographySettings.tsx:62-68). + pub fn reset(&mut self, slot: Slot) { + let d = Self::default(); + self.families[slot.index()] = d.families[slot.index()].clone(); + self.sizes[slot.index()] = d.sizes[slot.index()]; + } + + pub fn is_default(&self, slot: Slot) -> bool { + let d = Self::default(); + self.family(slot) == d.family(slot) && self.size(slot) == d.size(slot) + } + + /// The family and size the terminal preview uses: the code row's unless fonts are separate + /// (`resolveTerminalFontPreference`, `resolveTerminalFontSizePreference`). + pub fn terminal_slot(&self) -> Slot { + if self.separate { + Slot::Terminal + } else { + Slot::Code + } + } + + /// The prompt preview's family slot and size: the prompt row's when fonts are separate, else + /// the interface font at `text-sm` (`--font-size-prompt`, appearanceFonts.ts:92-95). + pub fn prompt_font(&self) -> (Slot, f32) { + if self.separate { + (Slot::Prompt, self.size(Slot::Prompt) as f32) + } else { + (Slot::Interface, self.size(Slot::Interface) as f32 * 0.875) + } + } + + /// A missing or invalid key falls back to its default; anything but an object is rejected. + pub fn parse(text: &str) -> Option { + let value: Value = serde_json::from_str(text).ok()?; + let map = fields(&value)?; + let d = Self::default(); + let mut out = d.clone(); + for slot in Slot::ALL { + if let Some(family) = map.get(slot.family_key()).and_then(Value::as_str) + && family.chars().count() <= MAX_FAMILY + { + out.families[slot.index()] = family.to_owned(); + } + if let Some(px) = map + .get(slot.size_key()) + .and_then(Value::as_u64) + .and_then(|px| u32::try_from(px).ok()) + .filter(|px| slot.range().contains(*px)) + { + out.sizes[slot.index()] = px; + } + } + let flag = + |key: &str, fallback: bool| map.get(key).and_then(Value::as_bool).unwrap_or(fallback); + out.smoothing = flag("fontSmoothing", d.smoothing); + out.word_wrap = flag("wordWrap", d.word_wrap); + out.separate = flag("typographyAdvanced", d.separate); + Some(out) + } + + pub fn entries(&self) -> Vec<(&'static str, Value)> { + let mut entries = Vec::with_capacity(11); + for slot in Slot::ALL { + entries.push((slot.family_key(), json!(self.family(slot)))); + entries.push((slot.size_key(), json!(self.size(slot)))); + } + entries.push(("fontSmoothing", json!(self.smoothing))); + entries.push(("wordWrap", json!(self.word_wrap))); + entries.push(("typographyAdvanced", json!(self.separate))); + entries + } + + /// Writes these keys into `existing`, keeping every key this section does not own. + pub fn to_json(&self, existing: Option<&str>) -> String { + let mut document = existing + .and_then(|text| serde_json::from_str::(text).ok()) + .filter(Value::is_object) + .unwrap_or_else(|| json!({})); + let target = match document.get_mut("settings") { + Some(inner) if inner.is_object() => inner, + _ => &mut document, + }; + if let Some(map) = target.as_object_mut() { + for (key, value) in self.entries() { + map.insert(key.to_owned(), value); + } + } + document.to_string() + } + + fn path() -> Option { + crate::settings::DeviceSettings::path() + } + + pub fn load() -> Self { + Self::path() + .and_then(|path| std::fs::read_to_string(path).ok()) + .and_then(|text| Self::parse(&text)) + .unwrap_or_default() + } + + /// Re-reads the file first, so keys the other tabs saved in the meantime survive. + pub fn save(&self) { + if let Some(path) = Self::path() { + let existing = std::fs::read_to_string(&path).ok(); + let _ = std::fs::write(path, self.to_json(existing.as_deref())); + } + } +} + +/// What the picker shows for the default family: `resolveDefaultFamilyLabel` over the web's +/// default stacks, which resolves to the Apple faces on macOS and to the web's fallback labels +/// elsewhere (TypographySettings.tsx:48-49, appearanceFonts.ts:243-250). +pub fn default_family_label(monospace: bool) -> &'static str { + match (cfg!(target_os = "macos"), monospace) { + (true, false) => "SF Pro", + (true, true) => "SF Mono", + (false, false) => "System default", + (false, true) => "System monospace", + } +} + +const fn item(id: &'static str, title: &'static str, terms: &'static str) -> SearchItem { + SearchItem { + id, + title, + section: Section::Appearance, + terms, + desktop_only: false, + } +} + +/// The Typography `searchableSetting` entries (settingsSearch.ts:371-412), in the web's order. +/// Font smoothing is macOS-only on the web (`macOnly`). +pub const TYPOGRAPHY_ITEMS: &[SearchItem] = &[ + item( + "interface-font", + "Interface font", + "typography family size system sans", + ), + item( + "prompt-font", + "Prompt font", + "typography family size composer input", + ), + item( + "code-font", + "Code font", + "typography family size monospace code blocks diffs file previews", + ), + item( + "terminal-font", + "Terminal font", + "typography family size monospace output", + ), + item( + "separate-fonts", + "Separate fonts", + "typography advanced prompt code terminal font family size individually", + ), + item( + "font-smoothing", + "Font smoothing", + "typography text grayscale anti aliasing macos thin", + ), + item( + "word-wrap", + "Word wrap", + "long lines code blocks diffs file previews", + ), +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_the_contract() { + let d = TypographySettings::default(); + assert_eq!(d.sizes, [16, 14, 13, 12]); + assert_eq!(d.families, ["", "", "", ""].map(String::from)); + assert!(d.smoothing && d.word_wrap && !d.separate); + } + + #[test] + fn round_trips_and_keeps_foreign_keys() { + let mut s = TypographySettings::default(); + s.set_family(Slot::Code, "Menlo"); + s.set_size(Slot::Terminal, 9); + s.word_wrap = false; + s.separate = true; + let existing = r#"{"settings":{"diffLayout":"split"}}"#; + let text = s.to_json(Some(existing)); + assert_eq!(TypographySettings::parse(&text), Some(s)); + let value: Value = serde_json::from_str(&text).unwrap(); + assert_eq!(value["settings"]["diffLayout"], "split"); + assert_eq!(value["settings"]["fontFamilyCode"], "Menlo"); + } + + #[test] + fn invalid_keys_take_defaults() { + let parsed = TypographySettings::parse( + r#"{"fontSizeCode":99,"fontSizeInterface":"big","fontFamilySans":7,"wordWrap":"no"}"#, + ) + .unwrap(); + assert_eq!(parsed, TypographySettings::default()); + assert_eq!(TypographySettings::parse("[]"), None); + } + + #[test] + fn size_range_bounds_follow_the_contract() { + assert!(CODE_SIZE.contains(10) && CODE_SIZE.contains(18) && !CODE_SIZE.contains(19)); + assert!(!TERMINAL_SIZE.contains(7) && TERMINAL_SIZE.contains(20)); + let mut s = TypographySettings::default(); + s.set_size(Slot::Code, 40); + assert_eq!(s.size(Slot::Code), 13); + assert_eq!(CODE_SIZE.options().count(), 9); + } + + #[test] + fn reset_restores_family_and_size_of_one_row() { + let mut s = TypographySettings::default(); + s.set_family(Slot::Interface, "Georgia"); + s.set_size(Slot::Interface, 18); + s.set_size(Slot::Prompt, 18); + assert!(!s.is_default(Slot::Interface)); + s.reset(Slot::Interface); + assert!(s.is_default(Slot::Interface)); + assert_eq!(s.size(Slot::Prompt), 18); + } + + #[test] + fn previews_follow_the_separate_flag() { + let mut s = TypographySettings::default(); + assert_eq!(s.terminal_slot(), Slot::Code); + assert_eq!(s.prompt_font(), (Slot::Interface, 14.)); + s.separate = true; + assert_eq!(s.terminal_slot(), Slot::Terminal); + assert_eq!(s.prompt_font(), (Slot::Prompt, 14.)); + } + + #[test] + fn items_are_searchable_by_the_web_terms() { + let hits = crate::settings::search("grayscale", crate::settings::searchable()); + assert!(hits.iter().any(|item| item.id == "font-smoothing")); + } +} diff --git a/apps/desktop-gpui/src/shell.rs b/apps/desktop-gpui/src/shell.rs index 3f15fe188cc2..9cbef0df9a87 100644 --- a/apps/desktop-gpui/src/shell.rs +++ b/apps/desktop-gpui/src/shell.rs @@ -451,6 +451,7 @@ impl ShellState { .clone() .filter(|path| !path.trim().is_empty()), has_run: thread.latest_run_id.is_some(), + updated_at_ms: timestamp_ms(&thread.updated_at), }; let rows = SECTION_ORDER .into_iter() diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index 02b9de201736..0fd39488fedb 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -123,6 +123,8 @@ pub struct ThreadRow { pub worktree_path: Option, /// Whether the thread has ever run, which un-quiets the details card's changes row. pub has_run: bool, + /// `updatedAt`, which the composer's Previous worktree row sorts by; `None` if unparseable. + pub updated_at_ms: Option, } /// The card's error row: `usage_limit` is a warning, any other class an error. diff --git a/apps/desktop-gpui/src/strip.rs b/apps/desktop-gpui/src/strip.rs index 8fdfd20e1969..21403ffb55f8 100644 --- a/apps/desktop-gpui/src/strip.rs +++ b/apps/desktop-gpui/src/strip.rs @@ -22,9 +22,6 @@ pub enum EnvMode { } impl EnvMode { - /// The workspace menu's rows, in the web's order. - pub const ALL: [Self; 2] = [Self::Local, Self::Worktree]; - fn parse(text: &str) -> Option { match text { "local" => Some(Self::Local), diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index e396bd872226..2108d0b53e40 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -24,6 +24,7 @@ use crate::terminal_view::TerminalView; use crate::thread::{Entry, Kind}; use crate::thread_card::{self, ChainKind, PrLine}; use crate::toast::{Kind as ToastKind, Toast, ToastHost, ToastId}; +use crate::workspace::{self, Choice, Draft}; use gpui::{ Anchor, Animation, AnimationExt as _, AnyElement, App, Bounds, Context, DragMoveEvent, Entity, Focusable as _, FollowMode, FontWeight, Hsla, IntoElement, Keystroke, ListAlignment, @@ -55,10 +56,13 @@ use std::{ const TERMINAL_SIDEBAR_WIDTH: f32 = 144.; /// The drag handle on the terminal drawer's top edge. const TERMINAL_HANDLE_HEIGHT: f32 = 8.; +mod composer_menu; mod model_popover; mod right_panel; +mod run_on_menu; mod settings; mod usage_dialog; +mod workspace_menu; pub use settings::bindings as settings_bindings; pub use usage_dialog::bindings as usage_bindings; @@ -89,6 +93,8 @@ actions!( ToggleSidebar, DismissSidebar, OpenPermissions, + OpenWorkspace, + UsePreviousWorktree, GoBack, GoForward, ToggleDetails, @@ -212,6 +218,14 @@ pub struct MainView { send_hovered: bool, permissions_open: bool, workspace_open: bool, + host_open: bool, + host_highlight: usize, + /// The row the Workspace menu's keys and pointer have lit. + workspace_highlight: usize, + /// Routes the menu's keys while it is open. + workspace_keys: Option, + /// The worktree a draft was pointed at by Previous worktree, by project id. + draft_worktrees: HashMap, /// The workspace mode the user picked for a draft, by project id. env_modes: HashMap, /// The ref a new worktree will start from, by project id. @@ -311,6 +325,8 @@ struct Sending { opens: Option, /// The draft's details key, whose model pick the new thread inherits. from_key: Option, + /// The project whose draft this send consumes. + draft_project: Option, /// The attachments the message carries, which leave the composer with it. attachments: Vec, } @@ -402,6 +418,11 @@ impl MainView { send_hovered: false, permissions_open: false, workspace_open: false, + host_open: false, + host_highlight: 0, + workspace_highlight: 0, + workspace_keys: None, + draft_worktrees: HashMap::new(), env_modes: HashMap::new(), draft_bases: HashMap::new(), branch_picker, @@ -716,11 +737,17 @@ impl MainView { }), picked: pick.is_some(), runtime_mode: self.runtime_mode.unwrap_or(defaults.runtime_mode), - workspace: send::Workspace::resolve( - mode, - strip::shown_branch(mode, current, base), - defaults.start_from_origin, - ), + workspace: match project.and_then(|entry| self.draft_worktrees.get(&entry.id)) { + Some(seed) => Ok(send::Workspace::Existing { + worktree_path: &seed.worktree_path, + branch: seed.branch.as_deref(), + }), + None => send::Workspace::resolve( + mode, + strip::shown_branch(mode, current, base), + defaults.start_from_origin, + ), + }, running: self.thread_running(), attachments: &self.attachments, }; @@ -732,9 +759,9 @@ impl MainView { message: new_project_id(), }, ) - .map(|plan| (plan, key)) + .map(|plan| (plan, key, project_id.map(str::to_owned))) }; - let (plan, from_key) = match planned { + let (plan, from_key, draft_project) = match planned { Ok(planned) => planned, Err(message) => return self.error_toast("Could not send the message", message, cx), }; @@ -753,6 +780,7 @@ impl MainView { self.sending = Some(Sending { opens: plan.opens, from_key, + draft_project, attachments: self .attachments .iter() @@ -1258,6 +1286,9 @@ impl MainView { self.attachments .retain(|item| !sent.attachments.contains(&item.id)); if let Some(thread) = sent.opens { + if let Some(project) = &sent.draft_project { + self.draft_worktrees.remove(project); + } if let Some(pick) = sent.from_key.and_then(|key| self.model_picks.remove(&key)) { self.model_picks.insert(thread.clone(), pick); } @@ -3688,24 +3719,55 @@ impl MainView { |c| c.defaults(Some(&project.id)), ); let environment = strip::environment_label(label); - let workspace = self - .env_modes - .get(&project.id) - .copied() - .unwrap_or(defaults.env_mode); + let (draft, previous) = self.workspace_draft(project); + let workspace = draft.mode; let current = self.branches.get(&project.root).and_then(Option::as_ref); let is_repo = current.is_some(); let shown = strip::shown_branch(workspace, current, self.draft_bases.get(&project.id)); let branch_label = shown.map(|branch| branch.label(workspace, defaults.start_from_origin)); self.branch_picker .update(cx, |picker, _| picker.sync(&project.root, workspace)); - let branch = branch_label - .filter(|_| is_repo) - .map(|label| self.branch_popover(label, cx)); - let workspace_control = if is_repo { - Some(self.workspace_popover(&project.id, workspace, cx)) - } else { + // A reused worktree fixes the ref, so the control names its branch and does not open. + let branch = if !is_repo { None + } else if let Some(label) = draft.branch_label() { + Some( + strip_control("branch", Icons::GitBranch, label.clone(), label, false, cx) + .into_any_element(), + ) + } else { + branch_label.map(|label| self.branch_popover(label, cx)) + }; + let workspace_control = is_repo.then(|| { + workspace_menu::popover( + workspace_menu::Menu { + view: cx.entity(), + draft, + previous, + highlight: self.workspace_highlight, + }, + self.workspace_open, + WORKSPACE_SHORTCUT, + ) + }); + let host_control = if run_on_menu::is_select(&project.remote) { + run_on_menu::popover( + run_on_menu::Menu { + view: cx.entity(), + current: run_on_menu::Env { + label: environment.clone(), + machine: *machine, + }, + others: project.remote.clone(), + highlight: self.host_highlight, + }, + self.host_open, + HOST_SHORTCUT, + ) + } else { + // One environment is the only one a draft can run on: the web's locked label. + composer_menu::locked("host", machine_icon(*machine), environment.clone(), cx) + .into_any_element() }; let runtime_mode = self.runtime_mode.unwrap_or(defaults.runtime_mode); Some( @@ -3715,17 +3777,7 @@ impl MainView { .gap_1() .px_2() .pt_2() - .child( - // One environment is the only one a draft can run on, so the host is a label. - strip_control( - "host", - machine_icon(*machine), - environment.clone(), - strip::tooltip_text(&environment, Some(HOST_SHORTCUT)), - false, - cx, - ), - ) + .child(host_control) .children(is_repo.then(|| strip_separator(cx))) .children(workspace_control) .child(div().flex_1()) @@ -3743,78 +3795,196 @@ impl MainView { ) } - /// The web's `BranchToolbarEnvModeSelector`: a menu of the two workspace modes. - fn workspace_popover( - &self, - project_id: &str, - mode: EnvMode, + /// The workspace mode of a project's draft, its worktree, and the worktree Previous worktree + /// would reuse (`resolvePreviousWorktreeSeed` over the project's threads). + fn workspace_draft(&self, project: &ProjectEntry) -> (Draft, Option) { + let defaults = self.providers.as_ref().map_or_else( + || Defaults::resolve(None, None, None), + |catalog| catalog.defaults(Some(&project.id)), + ); + let draft = Draft { + mode: self + .env_modes + .get(&project.id) + .copied() + .unwrap_or(defaults.env_mode), + worktree: self.draft_worktrees.get(&project.id).cloned(), + }; + let current = draft + .worktree + .as_ref() + .map(|seed| seed.worktree_path.as_str()); + let previous = self.shell().and_then(|shell| { + workspace::previous_seed( + shell + .rows + .iter() + .filter(|row| row.project_id == project.id) + .map(|row| workspace::Thread { + branch: row.branch.as_deref(), + worktree_path: row.worktree_path.as_deref(), + updated_at_ms: row.updated_at_ms, + }), + current, + ) + }); + (draft, previous) + } + + /// Whether the composer shows the workspace control: a project, and a repository. + fn workspace_project(&self) -> Option<&ProjectEntry> { + let project = self.draft_project().filter(|entry| !entry.scratch)?; + self.branches + .get(&project.root) + .and_then(Option::as_ref) + .map(|_| project) + } + + /// Opens or closes the menu; opening lights the selected row and takes over the arrow keys. + pub(crate) fn set_workspace_open( + &mut self, + open: bool, + _: &mut Window, cx: &mut Context, - ) -> AnyElement { - let view = cx.entity(); - let project_id = project_id.to_owned(); - Popover::new("workspace") - .anchor(Anchor::BottomLeft) - .open(self.workspace_open) - .p_0() - .on_open_change({ - let view = view.clone(); - move |open, _, cx| { - view.update(cx, |this, cx| { - this.workspace_open = *open; - cx.notify(); - }); + ) { + self.workspace_open = open; + self.workspace_keys = None; + if open { + let lit = self.workspace_project().map(|project| { + let (draft, previous) = self.workspace_draft(project); + Draft::rows(previous.is_some()) + .iter() + .position(|row| *row == draft.selected()) + .unwrap_or(0) + }); + self.workspace_highlight = lit.unwrap_or(0); + let view = cx.weak_entity(); + self.workspace_keys = Some(cx.intercept_keystrokes(move |event, window, cx| { + let handled = view + .update(cx, |this, cx| { + this.workspace_key(&event.keystroke, window, cx) + }) + .unwrap_or(false); + if handled { + cx.stop_propagation(); } - }) - .trigger(StripTrigger { - id: "workspace", - icon: workspace_icon(mode), - label: mode.label().to_owned(), - tip: strip::tooltip_text(mode.label(), Some(WORKSPACE_SHORTCUT)), - shrink: false, - open: false, - }) - .content(move |_, _, cx| { - let theme = cx.theme(); - let (muted, hover, selected) = - (theme.muted_foreground, theme.list_hover, theme.list_active); - let rows = EnvMode::ALL.into_iter().map(|choice| { - let (view, project_id) = (view.clone(), project_id.clone()); - h_flex() - .id(("workspace-mode", choice as usize)) - .gap(px(6.)) - .px_2() - .py_1p5() - .items_center() - .rounded_md() - .text_sm() - .cursor_pointer() - .when(choice == mode, |row| row.bg(selected)) - .hover(move |style| style.bg(hover)) - .child(Icon::new(workspace_icon(choice)).size(px(12.))) - .child(div().flex_1().child(choice.label())) - .on_click(move |_, _, cx| { - view.update(cx, |this, cx| { - this.env_modes.insert(project_id.clone(), choice); - this.workspace_open = false; - cx.notify(); - }); - }) - }); - v_flex() - .w(px(224.)) - .p_1() - .child( - div() - .px_2() - .py_1() - .text_xs() - .font_weight(FontWeight::MEDIUM) - .text_color(muted) - .child("Workspace"), - ) - .children(rows) - }) - .into_any_element() + })); + } + cx.notify(); + } + + pub(crate) fn set_host_open(&mut self, open: bool, _: &mut Window, cx: &mut Context) { + self.host_open = open; + self.host_highlight = 0; + cx.notify(); + } + + pub(crate) fn highlight_host(&mut self, index: usize, cx: &mut Context) { + if self.host_highlight != index { + self.host_highlight = index; + cx.notify(); + } + } + + pub(crate) fn highlight_workspace(&mut self, index: usize, cx: &mut Context) { + if self.workspace_highlight != index { + self.workspace_highlight = index; + cx.notify(); + } + } + + /// Applies a row to the draft (`onEnvModeChange`, `onUsePreviousWorktree`) and returns focus + /// to the composer, as the web does (`scheduleComposerFocus`). + pub(crate) fn pick_workspace( + &mut self, + choice: Choice, + window: &mut Window, + cx: &mut Context, + ) { + self.workspace_open = false; + self.workspace_keys = None; + let Some(project) = self.workspace_project() else { + return cx.notify(); + }; + let id = project.id.clone(); + let (draft, previous) = self.workspace_draft(project); + let next = draft.choose(choice, previous.as_ref()); + self.env_modes.insert(id.clone(), next.mode); + match next.worktree { + Some(seed) => { + self.draft_worktrees.insert(id, seed); + } + None => { + self.draft_worktrees.remove(&id); + } + } + window.focus(&self.composer.focus_handle(cx), cx); + cx.notify(); + } + + /// The menu's keyboard, which the select gives the web: arrows and Home and End move the lit + /// row, Enter and Space choose it, Escape closes. Returns whether the key was taken. + fn workspace_key( + &mut self, + keystroke: &Keystroke, + window: &mut Window, + cx: &mut Context, + ) -> bool { + let modifiers = keystroke.modifiers; + if !self.workspace_open + || modifiers.platform + || modifiers.control + || modifiers.alt + || modifiers.shift + { + return false; + } + let Some(project) = self.workspace_project() else { + return false; + }; + let count = Draft::rows(self.workspace_draft(project).1.is_some()).len(); + match keystroke.key.as_str() { + "down" => { + self.workspace_highlight = model_picker::step(self.workspace_highlight, count, 1); + } + "up" => { + self.workspace_highlight = model_picker::step(self.workspace_highlight, count, -1); + } + "home" => self.workspace_highlight = 0, + "end" => self.workspace_highlight = count.saturating_sub(1), + "enter" | "space" => { + let (draft, previous) = self.workspace_draft(project); + let rows = Draft::rows(previous.is_some()); + let choice = rows.get(self.workspace_highlight).copied(); + let _ = draft; + match choice { + Some(choice) => self.pick_workspace(choice, window, cx), + None => return false, + } + } + "escape" => self.set_workspace_open(false, window, cx), + _ => return false, + } + cx.notify(); + true + } + + /// `composer.workspace`: opens the menu when the control is on screen. + fn open_workspace(&mut self, window: &mut Window, cx: &mut Context) { + if self.workspace_project().is_some() { + self.set_workspace_open(true, window, cx); + } + } + + /// `composer.previousWorktree` (`usePreviousWorktree`): reuses the previous worktree without + /// opening the menu, when one is offered. + fn use_previous_worktree(&mut self, window: &mut Window, cx: &mut Context) { + let offered = self + .workspace_project() + .is_some_and(|project| self.workspace_draft(project).1.is_some()); + if offered { + self.pick_workspace(Choice::Previous, window, cx); + } } /// The web's `BranchToolbarBranchSelector` in the composer strip. @@ -4572,13 +4742,6 @@ fn strip_separator(cx: &App) -> gpui::Div { .bg(cx.theme().border) } -fn workspace_icon(mode: EnvMode) -> Icons { - match mode { - EnvMode::Local => Icons::Folder, - EnvMode::Worktree => Icons::FolderGit2, - } -} - /// `runtimeModeConfig`'s icons. fn runtime_icon(mode: RuntimeMode) -> Icons { match mode { @@ -6199,6 +6362,12 @@ impl Render for MainView { cx.listener(|this, _: &DismissSidebar, _, cx| this.close_sidebar_overlay(cx)), ) .on_action(cx.listener(|this, _: &OpenPermissions, _, cx| this.open_permissions(cx))) + .on_action( + cx.listener(|this, _: &OpenWorkspace, window, cx| this.open_workspace(window, cx)), + ) + .on_action(cx.listener(|this, _: &UsePreviousWorktree, window, cx| { + this.use_previous_worktree(window, cx) + })) .on_action(cx.listener(|this, _: &ToggleDetails, _, cx| this.toggle_details(cx))) .on_action(cx.listener(|this, _: &OpenUsage, window, cx| { this.usage.update(cx, |usage, cx| usage.open(window, cx)); @@ -6347,6 +6516,7 @@ mod tests { last_error: None, worktree_path: None, has_run: false, + updated_at_ms: None, } } diff --git a/apps/desktop-gpui/src/ui/composer_menu.rs b/apps/desktop-gpui/src/ui/composer_menu.rs new file mode 100644 index 000000000000..b1bd31992cb8 --- /dev/null +++ b/apps/desktop-gpui/src/ui/composer_menu.rs @@ -0,0 +1,223 @@ +//! The look the composer's select menus share: `ComposerSelectControl` at `size="xs"` for the +//! trigger, and `select.tsx` `SelectPopup`, `SelectGroupLabel` and `SelectItem` for the popup. +//! Colors are the web's `:root` tokens, read from the settings table. + +use super::settings::tokens::Tokens; +use super::*; +use gpui::{BoxShadow, point, transparent_black}; + +/// `h-6 px-1.75 gap-1.5 rounded-(--control-radius)`, a transparent border, `text-xs`, a 12px +/// icon and a label that truncates at `max-w-60`. +fn control( + id: &'static str, + icon: Icons, + label: SharedString, + rest: Hsla, +) -> gpui::Stateful { + div() + .id(id) + .flex() + .flex_shrink_0() + .items_center() + .gap(px(6.)) + .h(px(24.)) + .px(px(7.)) + .rounded(px(8.)) + .border_1() + .border_color(transparent_black()) + .text_xs() + .text_color(rest) + .child(Icon::new(icon).size(px(12.))) + .child( + div() + .max_w(px(240.)) + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .child(label), + ) +} + +/// The label the web renders when nothing can be chosen: `text-muted-foreground/70`, no hover. +pub(super) fn locked( + id: &'static str, + icon: Icons, + label: impl Into, + cx: &App, +) -> gpui::Stateful { + let label = label.into(); + control( + id, + icon, + label.clone(), + Tokens::of(cx).muted_foreground.opacity(0.7), + ) + .tooltip(tooltip(label.to_string())) +} + +/// A select's trigger: muted at rest (`foreground/70` in light), `bg-accent` and full +/// foreground when hovered or open. +#[derive(IntoElement)] +pub(super) struct Trigger { + pub id: &'static str, + pub icon: Icons, + pub label: SharedString, + pub tip: String, + pub open: bool, +} + +impl Selectable for Trigger { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } + + fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + fn is_open(&self) -> bool { + self.open + } +} + +impl RenderOnce for Trigger { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let tokens = Tokens::of(cx); + let rest = if tokens.dark { + tokens.muted_foreground + } else { + tokens.foreground.opacity(0.7) + }; + let (accent, foreground, open) = (tokens.accent, tokens.foreground, self.open); + control(self.id, self.icon, self.label, rest) + .cursor_pointer() + .hover(move |style| style.bg(accent).text_color(foreground)) + .when(open, move |control| { + control.bg(accent).text_color(foreground) + }) + .when(!open, |control| control.tooltip(tooltip(self.tip))) + } +} + +/// How a row reads: `data-highlighted` is the lit row, and a disabled row is dimmed. +#[derive(Clone, Copy, Default)] +pub(super) struct RowState { + pub selected: bool, + pub lit: bool, + pub disabled: bool, + /// "Add to another machine": quiet until lit (`opacity-55 in-data-highlighted:opacity-100`). + pub quiet: bool, +} + +pub(super) type OnHover = Box; +pub(super) type OnClick = Box; + +pub(super) struct Row { + pub id: (&'static str, usize), + pub state: RowState, + pub body: AnyElement, + pub on_hover: OnHover, + pub on_click: OnClick, +} + +/// `inline-flex items-center gap-1.5` with a `size-3` icon and a label. +pub(super) fn plain_body(icon: Icons, label: impl IntoElement, cx: &App) -> gpui::Div { + h_flex() + .gap(px(6.)) + .items_center() + .whitespace_nowrap() + .child( + div() + .text_color(Tokens::of(cx).muted_foreground) + .child(Icon::new(icon).size(px(12.))), + ) + .child(label) +} + +/// The popup: `dropdown-glass` painted opaque on a `rounded-lg` shell, a `p-1` list, the group +/// label, then the rows. `width` is a fixed width; otherwise `min-w-[150px]`. +pub(super) fn popup( + group: &'static str, + width: Option, + rows: Vec, + cx: &App, +) -> gpui::Div { + let tokens = Tokens::of(cx); + let shadow = if tokens.dark { + (18., 44., 0.8) + } else { + (16., 40., 0.55) + }; + let rows = rows.into_iter().map(|row| { + let RowState { + selected, + lit, + disabled, + quiet, + } = row.state; + let (on_hover, on_click) = (row.on_hover, row.on_click); + div() + .id(row.id) + .flex() + .items_center() + .min_h(px(28.)) + .px_2() + .py_1() + .rounded(px(4.)) + .text_sm() + .line_height(px(20.)) + .text_color(tokens.foreground) + .when(selected, |row| row.bg(tokens.foreground.opacity(0.08))) + .when(lit, |row| { + row.bg(tokens.accent).text_color(tokens.accent_foreground) + }) + .when(quiet && !lit, |row| row.opacity(0.55)) + .when(disabled, |row| row.opacity(0.64)) + .when(!disabled, |row| { + row.cursor_pointer() + .on_hover(move |entered, _, cx| { + if *entered { + on_hover(cx); + } + }) + .on_click(move |_, window, cx| on_click(window, cx)) + }) + .child(row.body) + }); + v_flex() + .when_some(width, |popup, width| popup.w(width)) + .min_w(px(150.)) + .rounded(px(8.)) + .border_1() + .border_color(tokens.foreground.opacity(0.1)) + .bg(tokens.popover) + .text_color(tokens.foreground) + .shadow(vec![BoxShadow { + color: gpui::black().opacity(shadow.2), + offset: point(px(0.), px(shadow.0)), + blur_radius: px(shadow.1), + spread_radius: px(-18.), + inset: false, + }]) + .child( + v_flex() + .p_1() + .child( + div() + .px_2() + .py(px(6.)) + .text_xs() + .line_height(px(16.)) + .font_weight(FontWeight::MEDIUM) + .text_color(tokens.muted_foreground) + .child(group), + ) + .children(rows), + ) +} diff --git a/apps/desktop-gpui/src/ui/run_on_menu.rs b/apps/desktop-gpui/src/ui/run_on_menu.rs new file mode 100644 index 000000000000..63e7bc4d401e --- /dev/null +++ b/apps/desktop-gpui/src/ui/run_on_menu.rs @@ -0,0 +1,131 @@ +//! The web's `BranchToolbarEnvironmentSelector`: the "Run on" control left of the workspace +//! control. With one environment it is the web's locked label; with several it is a select whose +//! rows name the environments that have the project, plus "Add to another machine". +//! +//! A GPUI draft can only run on the environment the window is attached to, and the app has no +//! add-project-to-machine flow, so the other environments and "Add to another machine" render as +//! the web draws them but do nothing (the web disables a row without `onEnvironmentChange`). + +use super::composer_menu::{self, OnClick, OnHover, Row, RowState}; +use super::*; +use crate::projects::RemoteMember; + +/// The rows of the menu: this environment first, then the others that have the project. +pub(super) struct Env { + pub label: String, + pub machine: MachineKind, +} + +pub(super) struct Menu { + pub view: Entity, + pub current: Env, + pub others: Vec, + pub highlight: usize, +} + +/// `hasMultipleEnvironments`: the select exists only when another environment has the project. +pub(super) fn is_select(others: &[RemoteMember]) -> bool { + !others.is_empty() +} + +/// Rows before "Add to another machine": the environments, current first. +fn environments(menu: &Menu) -> Vec<(String, MachineKind)> { + std::iter::once((menu.current.label.clone(), menu.current.machine)) + .chain( + menu.others + .iter() + .map(|other| (other.label.clone(), other.machine)), + ) + .collect() +} + +/// The index `highlight` takes for "Add to another machine". +fn add_index(menu: &Menu) -> usize { + 1 + menu.others.len() +} + +pub(super) fn popover(menu: Menu, open: bool, shortcut: &str) -> AnyElement { + let label = menu.current.label.clone(); + let machine = menu.current.machine; + let view = menu.view.clone(); + Popover::new("host") + .anchor(Anchor::BottomLeft) + .offset(px(4.)) + .open(open) + .p_0() + .appearance(false) + .on_open_change(move |open, window, cx| { + view.update(cx, |this, cx| this.set_host_open(*open, window, cx)); + }) + .trigger(composer_menu::Trigger { + id: "host", + icon: machine_icon(machine), + tip: strip::tooltip_text(&label, Some(shortcut)), + label: label.into(), + open, + }) + .content(move |_, _, cx| content(&menu, cx)) + .into_any_element() +} + +fn content(menu: &Menu, cx: &App) -> gpui::Div { + let close = |view: &Entity| { + let view = view.clone(); + Box::new(move |window: &mut Window, cx: &mut App| { + view.update(cx, |this, cx| this.set_host_open(false, window, cx)); + }) as OnClick + }; + let hover = |view: &Entity, index: usize| { + let view = view.clone(); + Box::new(move |cx: &mut App| { + view.update(cx, |this, cx| this.highlight_host(index, cx)); + }) as OnHover + }; + let mut rows: Vec = environments(menu) + .into_iter() + .enumerate() + .map(|(index, (label, machine))| Row { + id: ("host-row", index), + state: RowState { + selected: index == 0, + lit: index == menu.highlight, + // Retargeting a draft is not supported here, as without `onEnvironmentChange`. + disabled: index != 0, + quiet: false, + }, + body: composer_menu::plain_body(machine_icon(machine), label, cx).into_any_element(), + on_hover: hover(&menu.view, index), + on_click: close(&menu.view), + }) + .collect(); + let add = add_index(menu); + rows.push(Row { + id: ("host-row", add), + state: RowState { + selected: false, + lit: add == menu.highlight, + // The add-project-to-machine dialog is not ported, so the row only closes the menu. + disabled: false, + quiet: true, + }, + body: composer_menu::plain_body(Icons::Plus, "Add to another machine", cx) + .into_any_element(), + on_hover: hover(&menu.view, add), + on_click: close(&menu.view), + }); + composer_menu::popup("Run on", None, rows, cx) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_select_needs_another_environment() { + assert!(!is_select(&[])); + assert!(is_select(&[RemoteMember { + label: "Other".to_owned(), + machine: MachineKind::Cloud, + }])); + } +} diff --git a/apps/desktop-gpui/src/ui/settings.rs b/apps/desktop-gpui/src/ui/settings.rs index c49fd17562fa..abedb437f399 100644 --- a/apps/desktop-gpui/src/ui/settings.rs +++ b/apps/desktop-gpui/src/ui/settings.rs @@ -1,13 +1,16 @@ //! The web's Settings route (`_chat.settings.tsx`) as an `AppRouteModal`, with its section //! navigation (`SettingsSidebarNav.tsx`) and the General tab (`GeneralSettingsPanel`). +mod appearance_panel; +mod appearance_themes; +mod appearance_typography; mod components; mod devices_panel; mod diffs_panel; mod labs_panel; mod scope_inheritance; mod scope_pickers; -mod tokens; +pub(crate) mod tokens; use super::*; use crate::assets::SNAP_SHOT; @@ -15,6 +18,8 @@ use crate::settings::{ self as model, DeviceSettings, FollowUp, GroupingMode, NAV_GROUPS, NotificationMode, QuitMode, SearchItem, Section, SendShortcut, SortOrder, TimeFormat, }; +use appearance_panel::AppearancePanel; +use appearance_typography::AppearanceTypography; use components::{ HoverHandler, KeyHandler, Menu, PickHandler, SelectTrigger, close_button, disclosure, ghost_button, group, reset_button, row, section, select_menu, select_menu_checked, switch, @@ -159,6 +164,8 @@ pub struct SettingsDialog { /// `AdvancedSettings`' local `open` state: not persisted, closed again on leaving the tab. advanced_open: bool, diffs: Entity, + appearance: Entity, + typography: Entity, labs: Entity, devices: Entity, scope: Entity, @@ -205,6 +212,8 @@ impl SettingsDialog { triggers: std::array::from_fn(|_| cx.focus_handle().tab_stop(true)), advanced_open: false, diffs: cx.new(DiffsPanel::new), + appearance: cx.new(AppearancePanel::new), + typography: cx.new(|cx| AppearanceTypography::new(window, cx)), labs, devices, scope, @@ -223,6 +232,8 @@ impl SettingsDialog { self.previous_focus = window.focused(cx); self.settings = DeviceSettings::load(); self.diffs.update(cx, |panel, cx| panel.reload(cx)); + self.appearance.update(cx, |panel, cx| panel.reload(cx)); + self.typography.update(cx, |panel, cx| panel.reload(cx)); self.labs.update(cx, |panel, cx| panel.reload(cx)); self.devices.update(cx, |panel, cx| panel.reload(cx)); self.menu = None; @@ -327,6 +338,17 @@ impl SettingsDialog { self.advanced_open = false; } self.advanced_open |= item.id == "skills-in-slash-menu"; + if crate::settings_appearance::is_advanced(item.id) { + self.appearance + .update(cx, |panel, cx| panel.open_advanced(cx)); + } + if matches!( + item.id, + "separate-fonts" | "font-smoothing" | "prompt-font" | "terminal-font" + ) { + self.typography + .update(cx, |panel, cx| panel.open_advanced(item.id, cx)); + } self.section = item.section; self.clear_search(window, cx); window.focus(&self.focus, cx); @@ -365,6 +387,18 @@ impl SettingsDialog { { return; } + if self + .appearance + .update(cx, |panel, cx| panel.close_menu(window, cx)) + { + return; + } + if self + .typography + .update(cx, |panel, cx| panel.close_menu(window, cx)) + { + return; + } if self.confirm_restore { self.confirm_restore = false; } else if self.menu.is_some() { @@ -408,6 +442,10 @@ impl SettingsDialog { self.last_grouping = GroupingMode::Repository; self.diffs .update(cx, |panel, cx| panel.restore_defaults(cx)); + self.appearance + .update(cx, |panel, cx| panel.restore_defaults(cx)); + self.typography + .update(cx, |panel, cx| panel.restore_defaults(cx)); self.update(|settings| *settings = DeviceSettings::default(), cx); } @@ -1027,6 +1065,15 @@ impl SettingsDialog { } else if self.section == Section::Diffs { self.diffs.update(cx, |panel, cx| panel.set_wide(wide, cx)); vec![self.diffs.clone().into_any_element()] + } else if self.section == Section::Appearance { + self.appearance + .update(cx, |panel, cx| panel.set_layout(wide, content_width, cx)); + self.typography + .update(cx, |panel, cx| panel.set_wide(wide, cx)); + vec![ + self.appearance.clone().into_any_element(), + self.typography.clone().into_any_element(), + ] } else if self.section == Section::Labs { self.labs.update(cx, |panel, cx| panel.set_wide(wide, cx)); vec![self.labs.clone().into_any_element()] diff --git a/apps/desktop-gpui/src/ui/settings/appearance_panel.rs b/apps/desktop-gpui/src/ui/settings/appearance_panel.rs new file mode 100644 index 000000000000..eeb68e02d5b2 --- /dev/null +++ b/apps/desktop-gpui/src/ui/settings/appearance_panel.rs @@ -0,0 +1,1039 @@ +//! The Appearance tab, minus typography: the color scheme tiles and theme cards of +//! `ThemeLibrary` (ThemeSettings.tsx:640-860) and the Interface section of +//! `AppearanceSettingsPanel` (AppearanceSettingsPanel.tsx:164-486). Theme, contrast, glass and +//! animation choices are saved but not yet applied to the running app. + +use super::appearance_themes::{orb, wireframe}; +use super::components::{ + Change, HoverHandler, KeyHandler, Menu, PickHandler, SelectTrigger, disclosure, group, + outline_button, popup_shell, reset_button, row, section, select_menu, slider, switch, +}; +use super::tokens::Tokens; +use super::*; +use crate::settings_appearance::{ + AppearanceSettings, CONTRAST, ChatWidth, ColorMode, EffortDisplay, EnvironmentIdentification, + GLASS, PANEL_MS, Slide, THREAD_ITEMS, ThemeChoice, ThemeId, +}; +use gpui::{BoxShadow, FocusHandle, KeyDownEvent, MouseButton, point, relative}; +use std::rc::Rc; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Choice { + Width, + Details, + Effort, + Environment, +} + +impl Choice { + const ALL: [Self; 4] = [Self::Width, Self::Details, Self::Effort, Self::Environment]; + + fn id(self) -> &'static str { + match self { + Self::Width => "settings-chat-width", + Self::Details => "settings-thread-details", + Self::Effort => "settings-effort-display", + Self::Environment => "settings-environment-identification", + } + } + + fn slot(self) -> usize { + Self::ALL.iter().position(|c| *c == self).unwrap_or(0) + } + + fn labels(self) -> Vec<&'static str> { + match self { + Self::Width => ChatWidth::ALL.iter().map(|v| v.menu_label()).collect(), + Self::Details => THREAD_ITEMS.iter().map(|item| item.label).collect(), + Self::Effort => EffortDisplay::ALL.iter().map(|v| v.menu_label()).collect(), + Self::Environment => EnvironmentIdentification::ALL + .iter() + .map(|v| v.label()) + .collect(), + } + } + + fn selected(self, settings: &AppearanceSettings) -> usize { + let index = match self { + Self::Width => ChatWidth::ALL + .iter() + .position(|v| *v == settings.chat_width), + Self::Effort => EffortDisplay::ALL + .iter() + .position(|v| *v == settings.effort_display), + Self::Environment => Some(settings.environment_identification.index()), + Self::Details => None, + }; + index.unwrap_or(0) + } + + fn value_label(self, settings: &AppearanceSettings) -> String { + match self { + Self::Width => settings.chat_width.label().into(), + Self::Effort => settings.effort_display.label().into(), + Self::Environment => settings.environment_identification.label().into(), + Self::Details => format!("{} of {} on", settings.items_on(), THREAD_ITEMS.len()), + } + } + + fn apply(self, settings: &mut AppearanceSettings, index: usize) { + match self { + Self::Width => settings.chat_width = ChatWidth::ALL[index], + Self::Effort => settings.effort_display = EffortDisplay::ALL[index], + Self::Environment => { + settings.environment_identification = EnvironmentIdentification::ALL[index] + } + Self::Details => {} + } + } +} + +struct SlideSpec { + id: &'static str, + range: Slide, + value: u32, + badge: String, + badge_width: f32, + write: fn(&mut AppearanceSettings, u32), +} + +pub struct AppearancePanel { + settings: AppearanceSettings, + theme: ThemeChoice, + menu: Option<(Choice, usize)>, + triggers: [FocusHandle; 4], + advanced_open: bool, + preview_open: bool, + wide: bool, + content_width: f32, +} + +impl AppearancePanel { + pub fn new(cx: &mut Context) -> Self { + Self { + settings: AppearanceSettings::load(), + theme: ThemeChoice::load(), + menu: None, + triggers: std::array::from_fn(|_| cx.focus_handle().tab_stop(true)), + advanced_open: false, + preview_open: true, + wide: true, + content_width: 896., + } + } + + pub fn reload(&mut self, cx: &mut Context) { + self.settings = AppearanceSettings::load(); + self.theme = ThemeChoice::load(); + self.menu = None; + cx.notify(); + } + + pub fn set_layout(&mut self, wide: bool, content_width: f32, cx: &mut Context) { + if self.wide != wide || self.content_width != content_width { + self.wide = wide; + self.content_width = content_width; + cx.notify(); + } + } + + pub fn open_advanced(&mut self, cx: &mut Context) { + self.advanced_open = true; + cx.notify(); + } + + pub fn restore_defaults(&mut self, cx: &mut Context) { + self.update(|settings| *settings = AppearanceSettings::default(), cx); + } + + pub fn close_menu(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let Some((choice, _)) = self.menu.take() else { + return false; + }; + window.focus(&self.triggers[choice.slot()], cx); + cx.notify(); + true + } + + fn open_menu(&mut self, choice: Choice, cx: &mut Context) { + self.menu = Some((choice, choice.selected(&self.settings))); + cx.notify(); + } + + fn pick(&mut self, choice: Choice, index: usize, window: &mut Window, cx: &mut Context) { + self.update(|settings| choice.apply(settings, index), cx); + self.close_menu(window, cx); + } + + fn toggle_item(&mut self, index: usize, cx: &mut Context) { + let item = &THREAD_ITEMS[index]; + self.update( + |settings| settings.set_item(item, !settings.item_on(item)), + cx, + ); + } + + fn update(&mut self, change: impl FnOnce(&mut AppearanceSettings), cx: &mut Context) { + change(&mut self.settings); + self.settings.save(); + cx.notify(); + } + + fn update_theme(&mut self, change: impl FnOnce(&mut ThemeChoice), cx: &mut Context) { + change(&mut self.theme); + self.theme.save(); + cx.notify(); + } + + fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { + let Some((choice, highlighted)) = self.menu else { + return; + }; + let count = choice.labels().len(); + match event.keystroke.key.as_str() { + "down" => self.menu = Some((choice, (highlighted + 1) % count)), + "up" => self.menu = Some((choice, (highlighted + count - 1) % count)), + "home" => self.menu = Some((choice, 0)), + "end" => self.menu = Some((choice, count - 1)), + "enter" | "space" if choice == Choice::Details => self.toggle_item(highlighted, cx), + "enter" | "space" => self.pick(choice, highlighted, window, cx), + _ => return, + } + cx.stop_propagation(); + cx.notify(); + } + + fn reset( + &self, + id: &'static str, + changed: bool, + restore: fn(&mut AppearanceSettings, &AppearanceSettings), + window: &mut Window, + cx: &mut Context, + ) -> Option { + let view = cx.entity(); + changed.then(|| { + reset_button( + id, + move |_, _, cx| { + view.update(cx, |this, cx| { + this.update(|s| restore(s, &AppearanceSettings::default()), cx) + }) + }, + window, + cx, + ) + }) + } + + fn flag( + &self, + id: &'static str, + checked: bool, + toggle: fn(&mut AppearanceSettings), + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let view = cx.entity(); + switch( + id, + checked, + Rc::new(move |_, cx| view.update(cx, |this, cx| this.update(toggle, cx))), + window, + cx, + ) + } + + /// `min-w-12 rounded-md bg-muted px-2 py-1` mono value readout beside a slider. + fn badge(text: String, min_width: f32, cx: &App) -> gpui::Div { + let theme = Tokens::of(cx); + div() + .min_w(px(min_width)) + .flex_shrink_0() + .px_2() + .py_1() + .rounded(px(6.)) + .bg(theme.muted) + .text_center() + .font_family(cx.theme().mono_font_family.clone()) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(theme.foreground) + .child(text) + } + + fn slide(&self, spec: SlideSpec, window: &mut Window, cx: &mut Context) -> gpui::Div { + let SlideSpec { + id, + range, + value, + badge, + badge_width, + write, + } = spec; + let view = cx.entity(); + let change: Change = Rc::new(move |value, _, cx| { + view.update(cx, |this, cx| this.update(|s| write(s, value), cx)) + }); + h_flex() + .w_full() + .items_center() + .gap(px(12.)) + .child(Self::badge(badge, badge_width, cx)) + .child( + div() + .flex_1() + .min_w_0() + .child(slider(id, range, value, change, window, cx)), + ) + } + + fn trigger(&self, choice: Choice, cx: &mut Context) -> SelectTrigger { + let view = cx.entity(); + let on_key: KeyHandler = Rc::new(move |event, _, cx| { + if matches!( + event.keystroke.key.as_str(), + "enter" | "space" | "down" | "up" + ) { + cx.stop_propagation(); + view.update(cx, |this, cx| this.open_menu(choice, cx)); + } + }); + SelectTrigger::new( + choice.id(), + choice.value_label(&self.settings), + 160., + self.triggers[choice.slot()].clone(), + on_key, + ) + } + + fn popover(&self, choice: Choice, cx: &mut Context) -> Popover { + let view = cx.entity(); + let open = matches!(self.menu, Some((open, _)) if open == choice); + Popover::new(("settings-appearance-select", choice.slot())) + .anchor(Anchor::TopRight) + .offset(px(4.)) + .open(open) + .p_0() + .appearance(false) + .on_open_change(move |opened, window, cx| { + view.update(cx, |this, cx| { + if *opened { + this.open_menu(choice, cx); + } else if this.menu.is_some_and(|(current, _)| current == choice) { + this.close_menu(window, cx); + } + }) + }) + } + + fn select(&self, choice: Choice, cx: &mut Context) -> AnyElement { + let view = cx.entity(); + let labels = choice.labels(); + let selected = choice.selected(&self.settings); + let highlighted = match self.menu { + Some((open, index)) if open == choice => index, + _ => usize::MAX, + }; + let on_pick: PickHandler = { + let view = view.clone(); + Rc::new(move |index, window, cx| { + view.update(cx, |this, cx| this.pick(choice, index, window, cx)) + }) + }; + let on_hover: HoverHandler = Rc::new(move |index, cx| { + view.update(cx, |this, cx| { + if this.menu.is_some_and(|(_, current)| current != index) { + this.menu = Some((choice, index)); + cx.notify(); + } + }) + }); + self.popover(choice, cx) + .trigger(self.trigger(choice, cx)) + .content(move |_, _, cx| { + select_menu( + &Menu { + id: choice.id(), + labels: labels.clone(), + selected, + highlighted, + width: 160., + on_pick: on_pick.clone(), + on_hover: on_hover.clone(), + }, + cx, + ) + }) + .into_any_element() + } + + /// `ThreadDetailsItemsMenu`: one checkbox row per card item; the menu stays open while + /// items are toggled. + fn details(&self, cx: &mut Context) -> AnyElement { + let view = cx.entity(); + let highlighted = match self.menu { + Some((Choice::Details, index)) => index, + _ => usize::MAX, + }; + let on: Vec = THREAD_ITEMS + .iter() + .map(|item| self.settings.item_on(item)) + .collect(); + self.popover(Choice::Details, cx) + .trigger(self.trigger(Choice::Details, cx)) + .content(move |_, _, cx| { + let theme = Tokens::of(cx); + let mut menu = popup_shell(224., cx); + for (index, item) in THREAD_ITEMS.iter().enumerate() { + let (toggle, hover) = (view.clone(), view.clone()); + menu = menu.child( + h_flex() + .id(("settings-thread-item", index)) + .min_h(px(28.)) + .items_center() + .gap_2() + .px_2() + .py_1() + .rounded(px(4.)) + .text_sm() + .text_color(theme.foreground) + .cursor_pointer() + .when(highlighted == index, |row| row.bg(theme.accent)) + .on_hover(move |entered, _, cx| { + if *entered { + hover.update(cx, |this, cx| { + if this.menu.is_some_and(|(_, current)| current != index) { + this.menu = Some((Choice::Details, index)); + cx.notify(); + } + }); + } + }) + .on_click(move |_, _, cx| { + toggle.update(cx, |this, cx| this.toggle_item(index, cx)) + }) + .child(div().size(px(14.)).flex_shrink_0().when(on[index], |slot| { + slot.child(Icon::new(Icons::Check).size(px(14.))) + })) + .child(item.label), + ); + } + menu + }) + .into_any_element() + } + + /// `PanelAnimationsPreview`: replay by click; the panels snap, as nothing animates here yet. + fn preview(&self, window: &mut Window, cx: &mut Context) -> AnyElement { + let _ = window; + let theme = Tokens::of(cx); + let view = cx.entity(); + let open = self.preview_open; + let line = |width: f32, alpha: f32| { + div() + .h(px(2.)) + .w(relative(width)) + .rounded_full() + .bg(theme.muted_foreground.opacity(alpha)) + }; + h_flex() + .id("settings-panel-animations-preview") + .h(px(40.)) + .w_full() + .overflow_hidden() + .p_1() + .rounded(px(8.)) + .border_1() + .border_color(theme.border) + .bg(theme.background) + .cursor_pointer() + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| { + this.preview_open = !this.preview_open; + cx.notify(); + }) + }) + .child( + div() + .h_full() + .flex_shrink_0() + .rounded(px(6.)) + .bg(theme.sidebar) + .w(px(if open { 16. } else { 0. })), + ) + .child( + v_flex() + .flex_1() + .min_w_0() + .px_1() + .child( + v_flex() + .flex_1() + .gap_1() + .pt_1() + .child(line(1., 0.25)) + .child(line(0.8, 0.2)) + .child(line(0.6, 0.15)), + ) + .when(open, |column| { + column.child( + h_flex() + .h(px(8.)) + .flex_shrink_0() + .items_center() + .px_2() + .border_t_1() + .border_color(theme.border.opacity(0.7)) + .bg(theme.foreground.opacity(0.05)) + .child( + div() + .h(px(1.)) + .w(relative(0.67)) + .bg(theme.muted_foreground.opacity(0.25)), + ), + ) + }), + ) + .child( + div() + .h_full() + .flex_shrink_0() + .rounded(px(6.)) + .bg(theme.muted) + .w(px(if open { 20. } else { 0. })), + ) + .into_any_element() + } + + fn mode_tiles(&self, cx: &mut Context) -> gpui::Div { + let theme = Tokens::of(cx); + h_flex() + .w_full() + .gap_3() + .children(ColorMode::ALL.iter().enumerate().map(|(index, &mode)| { + let active = self.theme.mode == mode; + let view = cx.entity(); + let tile = v_flex() + .id(("settings-color-mode", index)) + .flex_1() + .min_w_0() + .gap(px(6.)) + .p_2() + .rounded(px(12.)) + .border_1() + .cursor_pointer() + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| this.update_theme(|t| t.mode = mode, cx)) + }); + let tile = if active { + let ring = BoxShadow { + color: theme.ring, + offset: point(px(0.), px(0.)), + blur_radius: px(0.), + spread_radius: px(2.), + inset: true, + }; + tile.border_color(gpui::transparent_black()) + .bg(theme.accent.opacity(0.3)) + .shadow(vec![ring]) + } else { + let hover = theme.accent.opacity(0.1); + tile.border_color(theme.border.opacity(0.7)) + .bg(theme.card.opacity(0.6)) + .hover(move |style| style.bg(hover)) + }; + tile.child(wireframe(mode, self.theme, cx)).child( + div() + .flex() + .justify_center() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(if active { + theme.foreground + } else { + theme.muted_foreground + }) + .child(mode.label()), + ) + })) + } + + fn orb_button( + &self, + id: ThemeId, + dark: bool, + picked: bool, + cx: &mut Context, + ) -> AnyElement { + let theme = Tokens::of(cx); + let view = cx.entity(); + let tip = if dark { + "Use for dark mode only" + } else { + "Use for light mode only" + }; + let ring_slot = + (ThemeId::ALL.iter().position(|t| *t == id).unwrap_or(0) * 2) + dark as usize; + div() + .id(("settings-theme-orb", ring_slot)) + .relative() + .size(px(68.)) + .flex_shrink_0() + .p(px(6.)) + .rounded_full() + .cursor_pointer() + .tooltip(tooltip(tip)) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(move |_, _, cx| { + cx.stop_propagation(); + view.update(cx, |this, cx| { + this.update_theme(|t| t.pick_half(id, dark), cx) + }) + }) + .child(orb(id.orb(dark), dark, cx)) + .when(picked, |button| { + button + .child( + div() + .absolute() + .inset_0() + .rounded_full() + .border_2() + .border_color(theme.ring), + ) + .child( + div() + .absolute() + .bottom(px(2.)) + .right(px(2.)) + .size(px(20.)) + .flex() + .items_center() + .justify_center() + .rounded_full() + .border_1() + .border_color(theme.border.opacity(0.7)) + .bg(theme.background) + .text_color(theme.foreground) + .child( + Icon::new(if dark { Icons::Moon } else { Icons::Sun }) + .size(px(12.)), + ), + ) + }) + .into_any_element() + } + + fn theme_card( + &self, + index: usize, + id: ThemeId, + cx: &mut Context, + ) -> gpui::Stateful { + let theme = Tokens::of(cx); + let view = cx.entity(); + let (light, dark) = self.theme.picked(id); + let active = light || dark; + let hover = theme.accent.opacity(0.1); + let ring = BoxShadow { + color: theme.ring, + offset: point(px(0.), px(0.)), + blur_radius: px(0.), + spread_radius: px(1.), + inset: true, + }; + let card = v_flex() + .id(("settings-theme-card", index)) + .flex_1() + .min_w_0() + .overflow_hidden() + .rounded(px(12.)) + .border_1() + .border_color(theme.border.opacity(0.7)) + .cursor_pointer() + .tooltip(tooltip("Use for both light and dark")) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| this.update_theme(|t| t.pick_both(id), cx)) + }); + let card = if active { + card.bg(theme.accent.opacity(0.3)).shadow(vec![ring]) + } else { + card.bg(theme.card.opacity(0.6)) + .hover(move |style| style.bg(hover)) + }; + card.child( + h_flex() + .min_h(px(64.)) + .items_center() + .justify_center() + .gap(px(10.)) + .px_3() + .pt_3() + .child(self.orb_button(id, false, light, cx)) + .child(self.orb_button(id, true, dark, cx)), + ) + .child( + h_flex() + .items_center() + .gap_2() + .px_3() + .pb_3() + .pt_2() + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(theme.foreground) + .child(id.label()), + ) + .child( + div() + .id(("settings-theme-duplicate", index)) + .size(px(24.)) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .rounded(px(6.)) + .text_color(theme.muted_foreground) + .cursor_pointer() + .hover(move |style| style.bg(hover.opacity(3.))) + .tooltip(tooltip("Duplicate theme")) + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click(|_, _, cx| cx.stop_propagation()) + .child(Icon::new(Icons::Copy).size(px(14.))), + ), + ) + } + + /// `repeat(auto-fit, minmax(min(100%, 16rem), 1fr))` with `gap-2`. + fn theme_grid(&self, cx: &mut Context) -> gpui::Div { + let inner = self.content_width.min(896.) - 48.; + let columns = (((inner + 8.) / 264.).floor() as usize).max(1); + let mut grid = v_flex().w_full().gap_2(); + for (chunk, ids) in ThemeId::ALL.chunks(columns).enumerate() { + let mut line = h_flex().w_full().items_start().gap_2(); + for (offset, &id) in ids.iter().enumerate() { + line = line.child(self.theme_card(chunk * columns + offset, id, cx)); + } + for _ in ids.len()..columns { + line = line.child(div().flex_1().min_w_0()); + } + grid = grid.child(line); + } + grid + } + + fn themes(&self, cx: &mut Context) -> gpui::Div { + let theme = Tokens::of(cx); + let heading = |text: &'static str| { + div() + .text_base() + .font_weight(FontWeight::SEMIBOLD) + .text_color(theme.foreground) + .child(text) + }; + let inert = |id: &'static str, icon: Icons, label: &'static str, cx: &App| { + outline_button(id, cx) + .child( + Icon::new(icon) + .size(px(14.)) + .text_color(theme.muted_foreground), + ) + .child(label) + }; + let create = inert( + "settings-create-theme", + Icons::Paintbrush, + "Create theme", + cx, + ); + let add = inert("settings-add-theme", Icons::Plus, "Add theme", cx); + v_flex() + .w_full() + .gap_3() + .child( + h_flex() + .min_h(px(28.)) + .items_center() + .px_4() + .child(heading("Color scheme")), + ) + .child(self.mode_tiles(cx)) + .child( + h_flex() + .min_h(px(32.)) + .flex_wrap() + .items_center() + .justify_between() + .gap_3() + .px_4() + .pt_5() + .child(heading("Themes")) + .child( + h_flex() + .flex_wrap() + .items_center() + .justify_end() + .gap_2() + .child(create) + .child(add), + ), + ) + .child(self.theme_grid(cx)) + } +} + +impl Render for AppearancePanel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let s = self.settings.clone(); + let d = AppearanceSettings::default(); + let wide = self.wide; + let view = cx.entity(); + let rows = |items: Vec| -> Vec { + items + .into_iter() + .map(IntoElement::into_any_element) + .collect() + }; + + let contrast = self.slide( + SlideSpec { + id: "settings-appearance-contrast", + range: CONTRAST, + value: s.contrast, + badge: format!("{}%", s.contrast), + badge_width: 48., + write: |s, v| s.contrast = v, + }, + window, + cx, + ); + let glass = self.slide( + SlideSpec { + id: "settings-appearance-glass", + range: GLASS, + value: s.glass_opacity, + badge: format!("{}%", s.glass_opacity), + badge_width: 48., + write: |s, v| s.glass_opacity = v, + }, + window, + cx, + ); + let animation = self.slide( + SlideSpec { + id: "settings-panel-animation-duration", + range: PANEL_MS, + value: s.panel_animation_ms, + badge: format!("{} ms", s.panel_animation_ms), + badge_width: 64., + write: |s, v| s.panel_animation_ms = v, + }, + window, + cx, + ); + let animation_control = if wide { + h_flex() + .items_center() + .gap(px(16.)) + .child(div().w(px(112.)).child(self.preview(window, cx))) + .child(div().w(px(208.)).child(animation)) + } else { + h_flex() + .w_full() + .items_center() + .gap_3() + .child(div().w(px(80.)).child(self.preview(window, cx))) + .child(div().flex_1().min_w_0().child(animation)) + }; + + let advanced = vec![ + row( + "Panel animations", + "Set how fast panels open and close.", + self.reset( + "settings-reset-panel-animations", + s.panel_animation_ms != d.panel_animation_ms, + |s, d| s.panel_animation_ms = d.panel_animation_ms, + window, + cx, + ), + animation_control.into_any_element(), + wide, + cx, + ), + row( + "Environment identification", + "Choose how Dev and Nightly environments are identified.", + self.reset( + "settings-reset-environment-identification", + s.environment_identification != d.environment_identification, + |s, d| s.environment_identification = d.environment_identification, + window, + cx, + ), + self.select(Choice::Environment, cx), + wide, + cx, + ), + row( + "Composer context", + "Keep branch and worktree controls below the composer after a thread starts.", + self.reset( + "settings-reset-composer-context", + s.composer_context != d.composer_context, + |s, d| s.composer_context = d.composer_context, + window, + cx, + ), + self.flag( + "settings-composer-context", + s.composer_context, + |s| s.composer_context = !s.composer_context, + window, + cx, + ), + wide, + cx, + ), + ]; + let toggle_advanced = Rc::new(move |_: &mut Window, cx: &mut App| { + view.update(cx, |this, cx| { + this.advanced_open = !this.advanced_open; + cx.notify(); + }) + }); + let advanced = disclosure( + "settings-appearance-advanced", + self.advanced_open, + toggle_advanced, + advanced + .into_iter() + .map(IntoElement::into_any_element) + .collect(), + window, + cx, + ); + + let slider_width = |control: gpui::Div| control.w(px(208.)).into_any_element(); + let interface = group( + { + let mut items = rows(vec![ + row( + "Contrast", + "Adjust the contrast of colors and borders across the interface.", + self.reset( + "settings-reset-contrast", + s.contrast != d.contrast, + |s, d| s.contrast = d.contrast, + window, + cx, + ), + if wide { + slider_width(contrast) + } else { + contrast.into_any_element() + }, + wide, + cx, + ), + row( + "Glass opacity", + "Higher values make menus, dialogs, and the composer more solid.", + self.reset( + "settings-reset-glass-opacity", + s.glass_opacity != d.glass_opacity, + |s, d| s.glass_opacity = d.glass_opacity, + window, + cx, + ), + if wide { + slider_width(glass) + } else { + glass.into_any_element() + }, + wide, + cx, + ), + row( + "Chat width", + "Set how wide messages and the composer can grow on large screens.", + self.reset( + "settings-reset-chat-width", + s.chat_width != d.chat_width, + |s, d| s.chat_width = d.chat_width, + window, + cx, + ), + self.select(Choice::Width, cx), + wide, + cx, + ), + row( + "Thread details", + "Choose what the thread details card shows. You can also right-click the card.", + self.reset( + "settings-reset-thread-details", + s.items_changed(), + |s, _| s.reset_items(), + window, + cx, + ), + self.details(cx), + wide, + cx, + ), + row( + "Effort display", + "Show reasoning effort in the composer as its name, such as High, or as bars.", + self.reset( + "settings-reset-effort-display", + s.effort_display != d.effort_display, + |s, d| s.effort_display = d.effort_display, + window, + cx, + ), + self.select(Choice::Effort, cx), + wide, + cx, + ), + row( + "Render math", + "Typeset LaTeX math, such as $x^2$ or \\[x^2\\], in messages and Markdown previews. The composer keeps TeX source editable.", + self.reset( + "settings-reset-math-rendering", + s.math_rendering != d.math_rendering, + |s, d| s.math_rendering = d.math_rendering, + window, + cx, + ), + self.flag( + "settings-math-rendering", + s.math_rendering, + |s| s.math_rendering = !s.math_rendering, + window, + cx, + ), + wide, + cx, + ), + ]); + items.push(advanced); + items + }, + cx, + ); + v_flex() + .w_full() + .gap_8() + .on_key_down(cx.listener(|this, event, window, cx| this.key_down(event, window, cx))) + .child(self.themes(cx)) + .child(section("Interface", interface)) + } +} diff --git a/apps/desktop-gpui/src/ui/settings/appearance_themes.rs b/apps/desktop-gpui/src/ui/settings/appearance_themes.rs new file mode 100644 index 000000000000..a626815353dc --- /dev/null +++ b/apps/desktop-gpui/src/ui/settings/appearance_themes.rs @@ -0,0 +1,346 @@ +//! The wireframe and orb drawings of the Appearance tab: `ThemeWireframe.tsx` and +//! `ThemePreviewCircles.tsx`. gpui has no radial gradients, so each orb layer is a stack of +//! concentric translucent circles that steps through the same alpha ramp. + +use super::*; +use crate::settings_appearance::{ColorMode, OrbPalette, ThemeChoice, ThemeId, mix}; +use gpui::{BoxShadow, Hsla, Rgba, point, relative, rgb}; + +fn color(rgb: [f32; 3], a: f32) -> Hsla { + Rgba { + r: rgb[0], + g: rgb[1], + b: rgb[2], + a, + } + .into() +} + +/// The roles `ThemeWireframePane` paints. +struct Wire { + canvas: Hsla, + sidebar: Hsla, + surface: Hsla, + accent_surface: Hsla, + message_surface: Hsla, + action: Hsla, +} + +/// The default theme's roles are exact; the others derive theirs from their orb colors. +fn wire(theme: ThemeId, dark: bool) -> Wire { + if theme == ThemeId::Default { + let hex = |value: u32| -> Hsla { rgb(value).into() }; + return if dark { + Wire { + canvas: hex(0x0a0a0a), + sidebar: hex(0x0f0f10), + surface: hex(0x121212), + accent_surface: hex(0x27272a), + message_surface: hex(0x27272a), + action: hex(0x8b9cff), + } + } else { + Wire { + canvas: hex(0xfcfcfc), + sidebar: hex(0xfafafa), + surface: hex(0xffffff), + accent_surface: hex(0xf4f4f5), + message_surface: hex(0xe4e4e7), + action: hex(0x4f46e5), + } + }; + } + let palette = theme.orb(dark); + let (canvas, accent, action) = ( + palette.canvas.rgb(), + palette.accent.rgb(), + palette.action.rgb(), + ); + let (white, black) = ([1.; 3], [0.; 3]); + let tinted = |weight: f32| color(mix(canvas, accent, weight), 1.); + if dark { + Wire { + canvas: color(canvas, 1.), + sidebar: color(mix(canvas, white, 0.98), 1.), + surface: color(mix(canvas, white, 0.96), 1.), + accent_surface: tinted(0.8), + message_surface: tinted(0.7), + action: color(action, 1.), + } + } else { + Wire { + canvas: color(canvas, 1.), + sidebar: color(mix(canvas, black, 0.98), 1.), + surface: color(mix(canvas, white, 0.2), 1.), + accent_surface: tinted(0.88), + message_surface: tinted(0.8), + action: color(action, 1.), + } + } +} + +fn bit(left: f32, top: f32, width: f32, height: f32) -> gpui::Div { + div() + .absolute() + .left(relative(left)) + .top(relative(top)) + .w(relative(width)) + .h(relative(height)) +} + +/// One pane of `ThemeWireframePane`, every box placed by percent of the pane. +fn pane(wire: &Wire) -> gpui::Div { + let line: Hsla = Rgba { + r: 127. / 255., + g: 127. / 255., + b: 127. / 255., + a: 0.25, + } + .into(); + let (sm, md, lg) = (px(4.), px(6.), px(8.)); + let card_shadow = vec![BoxShadow { + color: gpui::black().opacity(0.14), + offset: point(px(0.), px(2.)), + blur_radius: px(5.), + spread_radius: px(0.), + inset: false, + }]; + let rows = (0..3).map(|row| { + let dot = match row { + 0 => rgb(0x34d399).into(), + 1 => wire.action, + _ => rgb(0xfbbf24).into(), + }; + div() + .absolute() + .left(relative(0.11)) + .right(relative(0.11)) + .top(relative(0.10 + 0.30 * row as f32)) + .h(relative(0.20)) + .flex() + .items_center() + .gap_1() + .child( + div() + .size(px(3.5)) + .flex_shrink_0() + .rounded_full() + .bg(dot) + .opacity(0.55), + ) + .child(div().h(px(4.)).w(relative(0.52)).rounded(sm).bg(line)) + }); + div() + .relative() + .size_full() + .bg(wire.canvas) + .child( + bit(0., 0., 0.22, 1.) + .bg(wire.sidebar) + .border_r_1() + .border_color(line), + ) + .child( + bit(0.03, 0.08, 0.16, 0.08) + .rounded(md) + .bg(wire.surface) + .border_1() + .border_color(line), + ) + .child( + bit(0.03, 0.22, 0.16, 0.07) + .rounded(md) + .bg(wire.accent_surface), + ) + .child( + bit(0.03, 0.32, 0.16, 0.07) + .rounded(md) + .bg(wire.message_surface) + .opacity(0.7), + ) + .child( + bit(0.03, 0.42, 0.16, 0.07) + .rounded(md) + .bg(wire.message_surface) + .opacity(0.5), + ) + .child( + bit(0.48, 0.11, 0.24, 0.09) + .rounded(lg) + .bg(wire.message_surface), + ) + .child(bit(0.27, 0.28, 0.34, 0.05).rounded(sm).bg(line)) + .child(bit(0.27, 0.38, 0.26, 0.05).rounded(sm).bg(line)) + .child( + bit(0.26, 0.77, 0.68, 0.15) + .flex() + .items_center() + .justify_between() + .px_1() + .rounded(md) + .bg(wire.surface) + .border_1() + .border_color(line) + .child( + div() + .h(relative(0.26)) + .w(relative(0.34)) + .rounded_full() + .bg(line) + .opacity(0.7), + ) + .child(div().size(px(11.)).rounded_full().bg(wire.action)), + ) + .child( + bit(0.75, 0.08, 0.20, 0.46) + .rounded(lg) + .bg(wire.surface) + .border_1() + .border_color(line) + .shadow(card_shadow) + .children(rows), + ) +} + +/// `ThemeWireframe` at `h-[8.75rem]`: one pane, or the light and dark panes split at the middle +/// for System, each clipped 1px short of the centre line. +pub(super) fn wireframe(mode: ColorMode, choice: ThemeChoice, cx: &App) -> gpui::Div { + let border = Tokens::of(cx).border.opacity(0.6); + let frame = div() + .relative() + .w_full() + .h(px(140.)) + .overflow_hidden() + .rounded(px(8.)) + .border_1() + .border_color(border); + let half = |left: bool, wire: &Wire| { + let clipper = div() + .absolute() + .top_0() + .h_full() + .w(relative(0.5)) + .overflow_hidden(); + let inner = div().absolute().top_0().h_full().w(relative(2.)); + let clipper = if left { + clipper.left_0() + } else { + clipper.right_0() + }; + let inner = if left { + inner.left_0() + } else { + inner.right_0() + }; + clipper.child(inner.child(pane(wire))) + }; + match mode { + ColorMode::System => frame + .child(half(true, &wire(choice.light, false))) + .child(half(false, &wire(choice.dark, true))), + ColorMode::Light => frame.child(pane(&wire(choice.light, false))), + ColorMode::Dark => frame.child(pane(&wire(choice.dark, true))), + } +} + +/// Alpha of a gradient with `(offset, alpha)` stops at distance fraction `t`. +fn ramp(stops: &[(f32, f32)], t: f32) -> f32 { + let mut previous = stops[0]; + for &stop in stops { + if t <= stop.0 { + let span = (stop.0 - previous.0).max(f32::EPSILON); + return previous.1 + (stop.1 - previous.1) * ((t - previous.0) / span).clamp(0., 1.); + } + previous = stop; + } + previous.1 +} + +/// One radial gradient as concentric circles, outermost first. +fn gradient( + rgb: [f32; 3], + center: (f32, f32), + stops: &[(f32, f32)], + steps: usize, + size: f32, +) -> Vec { + let (cx, cy) = (center.0 * size, center.1 * size); + let reach = [(0., 0.), (size, 0.), (0., size), (size, size)] + .iter() + .map(|(x, y)| ((x - cx).powi(2) + (y - cy).powi(2)).sqrt()) + .fold(0., f32::max); + let end = stops[stops.len() - 1].0; + let mut circles = Vec::new(); + let mut outer = 0.; + for step in 0..steps { + let radius = reach * end * (1. - step as f32 / steps as f32); + let next = reach * end * (1. - (step + 1) as f32 / steps as f32); + let t = ((radius + next) / 2.) / reach; + let total = ramp(stops, t).min(0.98); + let alpha = (1. - (1. - total) / (1. - outer)).max(0.); + outer = outer + alpha * (1. - outer); + circles.push( + div() + .absolute() + .left(px(cx - radius)) + .top(px(cy - radius)) + .size(px(radius * 2.)) + .rounded_full() + .bg(color(rgb, alpha)), + ); + } + circles +} + +/// The 56px orb (`ThemePreviewCircles`): the canvas tinted to 80% of the way to the page color, +/// the accent glowing from one corner and the message action from the opposite one. +pub(super) fn orb(palette: OrbPalette, dark: bool, cx: &App) -> gpui::Div { + const INNER: f32 = 52.; + let theme = Tokens::of(cx); + let target = if dark { + [9. / 255., 9. / 255., 11. / 255.] + } else { + [1.; 3] + }; + let base = mix(palette.canvas.rgb(), target, 0.8); + let accent = palette.accent.rgb(); + let action = palette.action.rgb(); + let (accent_stops, accent_center, action_center) = if dark { + ( + [(0., 1.), (0.28, 0.62), (0.58, 0.)], + (0.28, 0.78), + (0.82, 0.18), + ) + } else { + ( + [(0., 1.), (0.28, 0.72), (0.58, 0.)], + (0.72, 0.22), + (0.18, 0.82), + ) + }; + let shadow = BoxShadow { + color: gpui::black().opacity(if dark { 0.18 } else { 0.08 }), + offset: point(px(0.), px(1.)), + blur_radius: px(2.), + spread_radius: px(0.), + inset: false, + }; + div() + .relative() + .size(px(56.)) + .flex_shrink_0() + .overflow_hidden() + .rounded_full() + .border_2() + .border_color(theme.background) + .bg(color(base, 1.)) + .shadow(vec![shadow]) + .children(gradient(accent, accent_center, &accent_stops, 12, INNER)) + .children(gradient( + action, + action_center, + &[(0., 0.45), (0.55, 0.)], + 6, + INNER, + )) +} diff --git a/apps/desktop-gpui/src/ui/settings/appearance_typography.rs b/apps/desktop-gpui/src/ui/settings/appearance_typography.rs new file mode 100644 index 000000000000..d88998a595ba --- /dev/null +++ b/apps/desktop-gpui/src/ui/settings/appearance_typography.rs @@ -0,0 +1,1086 @@ +//! The Typography section of Settings > Appearance: `TypographySection` +//! (TypographySettings.tsx:317-349) with its font rows (`FontFamilySettingsRow`, 351-560), the +//! `FontFamilyPicker` (FontFamilyPicker.tsx) and the three previews (SettingsFontPreviews.tsx). +//! +//! The values are device settings (`settings_typography`); nothing here applies them to the rest +//! of the app yet, so the previews are where a choice shows. + +use super::components::{ + HoverHandler, KeyHandler, Menu, PickHandler, SelectTrigger, Toggle, disclosure, group, + reset_button, row, row_with_preview, section, select_menu, switch, +}; +use super::scope_pickers::{Picker, PickerStyle, picker}; +use super::tokens::Tokens; +use super::*; +use crate::settings_labs::{Choice, ChoiceIcon}; +use crate::settings_typography::{Slot, TypographySettings, default_family_label}; +use gpui::{ + FocusHandle, FontWeight, HighlightStyle, Hsla, KeyDownEvent, StyledText, UnderlineStyle, rgb, +}; +use std::rc::Rc; + +/// The whole px range of the four selects, as labels (`{px} px`). +const PX: [&str; 13] = [ + "8 px", "9 px", "10 px", "11 px", "12 px", "13 px", "14 px", "15 px", "16 px", "17 px", + "18 px", "19 px", "20 px", +]; +const PX_FROM: u32 = 8; + +fn px_label(px: u32) -> &'static str { + PX[(px.clamp(PX_FROM, PX_FROM + PX.len() as u32 - 1) - PX_FROM) as usize] +} + +fn px_labels(slot: Slot) -> Vec<&'static str> { + slot.range().options().map(px_label).collect() +} + +fn slot_index(slot: Slot) -> usize { + Slot::ALL.iter().position(|s| *s == slot).unwrap_or(0) +} + +/// `w-22` on the size select, `sm:w-44` on the family picker. +const SIZE_WIDTH: f32 = 88.; +const FAMILY_WIDTH: f32 = 176.; + +/// The transcript's height in rows (`TERMINAL_PREVIEW_ROWS`). +const TERMINAL_ROWS: usize = 12; + +struct Ids { + picker: &'static str, + size: &'static str, + reset: &'static str, +} + +fn ids(slot: Slot) -> Ids { + match slot { + Slot::Interface => Ids { + picker: "settings-font-family-interface", + size: "settings-font-size-interface", + reset: "settings-reset-interface-font", + }, + Slot::Prompt => Ids { + picker: "settings-font-family-prompt", + size: "settings-font-size-prompt", + reset: "settings-reset-prompt-font", + }, + Slot::Code => Ids { + picker: "settings-font-family-code", + size: "settings-font-size-code", + reset: "settings-reset-code-font", + }, + Slot::Terminal => Ids { + picker: "settings-font-family-terminal", + size: "settings-font-size-terminal", + reset: "settings-reset-terminal-font", + }, + } +} + +fn description(slot: Slot, separate: bool) -> &'static str { + match slot { + Slot::Interface => "Everything outside code blocks and the terminal.", + Slot::Prompt => "Only the box you write prompts in. Mono works well here.", + Slot::Code if separate => "Code blocks, diffs, and file previews.", + Slot::Code => "Code blocks, diffs, file previews, and the terminal.", + Slot::Terminal => "Terminal output, independent from code blocks and diffs.", + } +} + +fn title(slot: Slot, separate: bool) -> &'static str { + match slot { + Slot::Interface => "Interface font", + Slot::Prompt => "Prompt font", + Slot::Code if separate => "Code font", + Slot::Code => "Monospace font", + Slot::Terminal => "Terminal font", + } +} + +/// `isMonospaceFamily`: every glyph of a fixed-width face advances the same. +fn is_monospace(family: &str, cx: &App) -> bool { + let text = cx.text_system(); + let id = text.resolve_font(&gpui::font(family.to_owned())); + let size = px(16.); + match (text.advance(id, size, 'i'), text.advance(id, size, 'W')) { + (Ok(narrow), Ok(wide)) => (f32::from(narrow.width) - f32::from(wide.width)).abs() < 0.01, + _ => false, + } +} + +pub struct AppearanceTypography { + settings: TypographySettings, + families: Vec, + font_open: Option, + size_menu: Option<(Slot, usize)>, + search: Entity, + size_triggers: [FocusHandle; 4], + advanced_open: bool, + notice: Option<(Slot, String)>, + wide: bool, + _subscription: Subscription, +} + +impl AppearanceTypography { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search fonts\u{2026}")); + let subscription = cx.subscribe(&search, |_, _, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + cx.notify(); + } + }); + let families = cx + .text_system() + .all_font_names() + .into_iter() + .filter(|name| !name.starts_with('.')) + .map(|name| Choice { + value: name.clone(), + label: name, + secondary: String::new(), + offline: false, + icon: ChoiceIcon::None, + }) + .collect(); + Self { + settings: TypographySettings::load(), + families, + font_open: None, + size_menu: None, + search, + size_triggers: std::array::from_fn(|_| cx.focus_handle().tab_stop(true)), + advanced_open: false, + notice: None, + wide: true, + _subscription: subscription, + } + } + + pub fn set_wide(&mut self, wide: bool, cx: &mut Context) { + if self.wide != wide { + self.wide = wide; + cx.notify(); + } + } + + /// Re-reads the file and closes any menu, as the dialog does when it opens. + pub fn reload(&mut self, cx: &mut Context) { + self.settings = TypographySettings::load(); + self.font_open = None; + self.size_menu = None; + self.notice = None; + cx.notify(); + } + + /// A search result for a row of the Advanced disclosure opens it. A prompt or terminal font + /// result also turns separate fonts on, since those rows only exist then + /// (`isAdvancedTypographyTarget`, `revealSettingsSearchTarget`). + pub fn open_advanced(&mut self, id: &str, cx: &mut Context) { + match id { + "separate-fonts" | "font-smoothing" => self.advanced_open = true, + "prompt-font" | "terminal-font" if !self.settings.separate => { + self.update(|s| s.separate = true, cx); + } + _ => return, + } + cx.notify(); + } + + /// "Restore defaults" resets the font settings; the separate-fonts flag is a view preference + /// the web keeps in `localStorage` outside the settings, so it stays. + pub fn restore_defaults(&mut self, cx: &mut Context) { + self.notice = None; + self.font_open = None; + self.size_menu = None; + let separate = self.settings.separate; + self.update( + |s| { + *s = TypographySettings::default(); + s.separate = separate; + }, + cx, + ); + } + + /// Closes an open picker or menu; returns whether there was one, so Escape stops there. + pub fn close_menu(&mut self, window: &mut Window, cx: &mut Context) -> bool { + if let Some((slot, _)) = self.size_menu.take() { + window.focus(&self.size_triggers[slot_index(slot)], cx); + cx.notify(); + return true; + } + let was = self.font_open.take().is_some(); + if was { + cx.notify(); + } + was + } + + fn update(&mut self, change: impl FnOnce(&mut TypographySettings), cx: &mut Context) { + change(&mut self.settings); + self.settings.save(); + cx.notify(); + } + + fn set_font_open( + &mut self, + slot: Slot, + opened: bool, + window: &mut Window, + cx: &mut Context, + ) { + if opened { + self.font_open = Some(slot); + self.size_menu = None; + self.notice = None; + self.search + .update(cx, |input, cx| input.set_value("", window, cx)); + let handle = self.search.focus_handle(cx); + window.focus(&handle, cx); + } else if self.font_open == Some(slot) { + self.font_open = None; + } + cx.notify(); + } + + /// `handlePick`: a non-monospace family is refused for code and terminal. + fn pick_family(&mut self, slot: Slot, family: &str, cx: &mut Context) { + self.font_open = None; + if !family.is_empty() && slot.requires_monospace() && !is_monospace(family, cx) { + self.notice = Some(( + slot, + format!( + "\"{family}\" isn't monospace. Code and terminal need a fixed-width font, so the current font was kept." + ), + )); + cx.notify(); + return; + } + self.notice = None; + self.update(|s| s.set_family(slot, family), cx); + } + + fn open_size(&mut self, slot: Slot, cx: &mut Context) { + let current = self.settings.size(slot); + let index = slot + .range() + .options() + .position(|px| px == current) + .unwrap_or(0); + self.size_menu = Some((slot, index)); + self.font_open = None; + cx.notify(); + } + + fn pick_size(&mut self, slot: Slot, index: usize, window: &mut Window, cx: &mut Context) { + let px = slot.range().min + index as u32; + self.update(|s| s.set_size(slot, px), cx); + self.close_menu(window, cx); + } + + fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { + let Some((slot, highlighted)) = self.size_menu else { + return; + }; + let count = slot.range().options().count(); + match event.keystroke.key.as_str() { + "down" => self.size_menu = Some((slot, (highlighted + 1) % count)), + "up" => self.size_menu = Some((slot, (highlighted + count - 1) % count)), + "home" => self.size_menu = Some((slot, 0)), + "end" => self.size_menu = Some((slot, count - 1)), + "enter" | "space" => self.pick_size(slot, highlighted, window, cx), + _ => return, + } + cx.stop_propagation(); + cx.notify(); + } + + /// The family a slot's picker offers as its default. + fn default_label(&self, slot: Slot) -> String { + match slot { + Slot::Interface => default_family_label(false).to_owned(), + Slot::Prompt => { + let sans = self.settings.family(Slot::Interface); + if sans.is_empty() { + default_family_label(false).to_owned() + } else { + sans.to_owned() + } + } + Slot::Code | Slot::Terminal => default_family_label(true).to_owned(), + } + } + + fn family_picker(&self, slot: Slot, window: &mut Window, cx: &mut Context) -> AnyElement { + let view = cx.entity(); + let open = self.font_open == Some(slot); + let family = self.settings.family(slot).to_owned(); + let default = self.default_label(slot); + let label = if family.is_empty() { + default.clone() + } else { + family.clone() + }; + let spec = Picker { + id: ids(slot).picker, + label, + icon: None, + all: Some(Choice { + value: String::new(), + label: default, + secondary: String::new(), + offline: false, + icon: ChoiceIcon::None, + }), + // The list is only needed while the popup is open. + choices: if open { + self.families.clone() + } else { + Vec::new() + }, + selected: family, + open, + search: self.search.clone(), + query: if open { + self.search.read(cx).value().to_string() + } else { + String::new() + }, + on_open: { + let view = view.clone(); + Rc::new(move |opened, window, cx| { + view.update(cx, |this, cx| this.set_font_open(slot, opened, window, cx)) + }) + }, + on_pick: Rc::new(move |value, _, cx| { + view.update(cx, |this, cx| this.pick_family(slot, &value, cx)) + }), + style: PickerStyle { + trigger_width: Some(FAMILY_WIDTH), + fonts: true, + }, + }; + picker(spec, window, cx) + } + + /// The size `Select` (`w-22`, `{px} px` items without an indicator). + fn size_select(&self, slot: Slot, cx: &mut Context) -> AnyElement { + let view = cx.entity(); + let id = ids(slot).size; + let index = slot_index(slot); + let current = self.settings.size(slot); + let labels = px_labels(slot); + let selected = (current - slot.range().min) as usize; + let open = matches!(self.size_menu, Some((open, _)) if open == slot); + let highlighted = match self.size_menu { + Some((open, at)) if open == slot => at, + _ => usize::MAX, + }; + let on_key: KeyHandler = { + let view = view.clone(); + Rc::new(move |event, _, cx| { + if matches!( + event.keystroke.key.as_str(), + "enter" | "space" | "down" | "up" + ) { + cx.stop_propagation(); + view.update(cx, |this, cx| this.open_size(slot, cx)); + } + }) + }; + let trigger = SelectTrigger::new( + id, + px_label(current), + SIZE_WIDTH, + self.size_triggers[index].clone(), + on_key, + ); + let on_pick: PickHandler = { + let view = view.clone(); + Rc::new(move |at, window, cx| { + view.update(cx, |this, cx| this.pick_size(slot, at, window, cx)) + }) + }; + let on_hover: HoverHandler = { + let view = view.clone(); + Rc::new(move |at, cx| { + view.update(cx, |this, cx| { + if this.size_menu.is_some_and(|(_, current)| current != at) { + this.size_menu = Some((slot, at)); + cx.notify(); + } + }) + }) + }; + Popover::new(("settings-font-size-select", index)) + .anchor(Anchor::TopRight) + .offset(px(4.)) + .open(open) + .p_0() + .appearance(false) + .on_open_change(move |opened, window, cx| { + view.update(cx, |this, cx| { + if *opened { + this.open_size(slot, cx); + } else if this.size_menu.is_some_and(|(current, _)| current == slot) { + this.close_menu(window, cx); + } + }) + }) + .trigger(trigger) + .content(move |_, _, cx| { + select_menu( + &Menu { + id, + labels: labels.clone(), + selected, + highlighted, + width: SIZE_WIDTH, + on_pick: on_pick.clone(), + on_hover: on_hover.clone(), + }, + cx, + ) + }) + .into_any_element() + } + + fn font_row( + &self, + slot: Slot, + preview: Option, + window: &mut Window, + cx: &mut Context, + ) -> gpui::Div { + let view = cx.entity(); + let reset = (!self.settings.is_default(slot)).then(|| { + reset_button( + ids(slot).reset, + move |_, _, cx| { + view.update(cx, |this, cx| { + this.notice = None; + this.update(|s| s.reset(slot), cx) + }) + }, + window, + cx, + ) + }); + let control = h_flex() + .flex_wrap() + .items_center() + .gap_2() + .child(self.family_picker(slot, window, cx)) + .child(self.size_select(slot, cx)) + .into_any_element(); + let separate = self.settings.separate; + let (title, description) = (title(slot, separate), description(slot, separate)); + let notice = self + .notice + .as_ref() + .filter(|(at, _)| *at == slot) + .map(|(_, text)| { + let theme = Tokens::of(cx); + div() + .pt_1() + .text_xs() + .text_color(theme.destructive) + .child(text.clone()) + .into_any_element() + }); + if preview.is_none() && notice.is_none() { + return row(title, description, reset, control, self.wide, cx); + } + let body = v_flex() + .children(notice) + .children(preview) + .into_any_element(); + row_with_preview(title, description, reset, control, body, self.wide, cx) + } + + fn flag( + &self, + id: &'static str, + checked: bool, + toggle: fn(&mut TypographySettings), + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let view = cx.entity(); + switch( + id, + checked, + Rc::new(move |_, cx| view.update(cx, |this, cx| this.update(toggle, cx))), + window, + cx, + ) + } + + fn flag_reset( + &self, + id: &'static str, + changed: bool, + restore: fn(&mut TypographySettings, &TypographySettings), + window: &mut Window, + cx: &mut Context, + ) -> Option { + let view = cx.entity(); + changed.then(|| { + reset_button( + id, + move |_, _, cx| { + view.update(cx, |this, cx| { + this.update(|s| restore(s, &TypographySettings::default()), cx) + }) + }, + window, + cx, + ) + }) + } + + /// The family a preview is set in: the stored one, else the theme's. + fn family_of(&self, slot: Slot, cx: &App) -> SharedString { + let stored = self.settings.family(slot).trim(); + if !stored.is_empty() { + return SharedString::from(stored.to_owned()); + } + match slot { + Slot::Interface | Slot::Prompt => cx.theme().font_family.clone(), + Slot::Code | Slot::Terminal => cx.theme().mono_font_family.clone(), + } + } + + fn prompt_preview(&self, cx: &App) -> AnyElement { + let (slot, size) = self.settings.prompt_font(); + let family = if slot == Slot::Prompt && self.settings.family(Slot::Prompt).trim().is_empty() + { + self.family_of(Slot::Interface, cx) + } else { + self.family_of(slot, cx) + }; + prompt_preview(family, size, cx) + } + + fn code_preview(&self, cx: &App) -> AnyElement { + let size = self.settings.size(Slot::Code) as f32; + diff_preview(self.family_of(Slot::Code, cx), size, cx) + } + + fn terminal_preview(&self, cx: &App) -> AnyElement { + let slot = self.settings.terminal_slot(); + let size = self.settings.size(slot) as f32; + terminal_preview(self.family_of(slot, cx), size, cx) + } +} + +impl Render for AppearanceTypography { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let s = self.settings.clone(); + let d = TypographySettings::default(); + let wide = self.wide; + let mut rows: Vec = Vec::new(); + if s.separate { + let prompt = self.prompt_preview(cx); + let code = self.code_preview(cx); + let terminal = self.terminal_preview(cx); + rows.push( + self.font_row(Slot::Interface, None, window, cx) + .into_any_element(), + ); + rows.push( + self.font_row(Slot::Prompt, Some(prompt), window, cx) + .into_any_element(), + ); + rows.push( + self.font_row(Slot::Code, Some(code), window, cx) + .into_any_element(), + ); + rows.push( + self.font_row(Slot::Terminal, Some(terminal), window, cx) + .into_any_element(), + ); + } else { + let prompt = self.prompt_preview(cx); + let both = v_flex() + .child(self.code_preview(cx)) + .child(self.terminal_preview(cx)) + .into_any_element(); + rows.push( + self.font_row(Slot::Interface, Some(prompt), window, cx) + .into_any_element(), + ); + rows.push( + self.font_row(Slot::Code, Some(both), window, cx) + .into_any_element(), + ); + } + rows.push( + row( + "Word wrap", + "Wrap long lines in code blocks, diffs, and file previews by default.", + self.flag_reset( + "settings-reset-word-wrap", + s.word_wrap != d.word_wrap, + |s, d| s.word_wrap = d.word_wrap, + window, + cx, + ), + self.flag( + "settings-word-wrap", + s.word_wrap, + |s| s.word_wrap = !s.word_wrap, + window, + cx, + ), + wide, + cx, + ) + .into_any_element(), + ); + let mut advanced = vec![ + row( + "Separate fonts", + "Set the prompt, code, and terminal fonts separately instead of one monospace font.", + None, + self.flag( + "settings-separate-fonts", + s.separate, + |s| s.separate = !s.separate, + window, + cx, + ), + wide, + cx, + ) + .into_any_element(), + ]; + if cfg!(target_os = "macos") { + advanced.push( + row( + "Font smoothing", + "Use thinner grayscale text smoothing instead of the macOS default.", + self.flag_reset( + "settings-reset-font-smoothing", + s.smoothing != d.smoothing, + |s, d| s.smoothing = d.smoothing, + window, + cx, + ), + self.flag( + "settings-font-smoothing", + s.smoothing, + |s| s.smoothing = !s.smoothing, + window, + cx, + ), + wide, + cx, + ) + .into_any_element(), + ); + } + let view = cx.entity(); + let toggle: Toggle = Rc::new(move |_, cx| { + view.update(cx, |this, cx| { + this.advanced_open = !this.advanced_open; + cx.notify(); + }) + }); + rows.push(disclosure( + "settings-typography-advanced", + self.advanced_open, + toggle, + advanced, + window, + cx, + )); + v_flex() + .w_full() + .on_key_down(cx.listener(|this, event, window, cx| this.key_down(event, window, cx))) + .child(section("Typography", group(rows, cx))) + } +} + +/// One styled stretch of a preview line. +struct Seg { + text: &'static str, + color: Option, + bold: bool, + underline: bool, + background: Option, +} + +fn seg(text: &'static str, color: impl Into>) -> Seg { + Seg { + text, + color: color.into(), + bold: false, + underline: false, + background: None, + } +} + +impl Seg { + fn bold(mut self) -> Self { + self.bold = true; + self + } + + fn underline(mut self) -> Self { + self.underline = true; + self + } + + fn on(mut self, background: Hsla) -> Self { + self.background = Some(background); + self + } +} + +fn line(segments: Vec) -> StyledText { + let mut text = String::new(); + let mut runs = Vec::new(); + for segment in &segments { + let start = text.len(); + text.push_str(segment.text); + runs.push(( + start..text.len(), + HighlightStyle { + color: segment.color, + font_weight: segment.bold.then_some(FontWeight::BOLD), + background_color: segment.background, + underline: segment.underline.then(|| UnderlineStyle { + thickness: px(1.), + color: segment.color, + wavy: false, + }), + ..Default::default() + }, + )); + } + StyledText::new(text).with_highlights(runs) +} + +/// The chip accents of the composer: `ts-file` for a file, the skill color for a skill. They are +/// the OKLCH tokens of index.css converted to sRGB. +fn skill_accent(dark: bool) -> Hsla { + rgb(if dark { 0xbb6ac8 } else { 0x93439f }).into() +} + +fn file_accent(dark: bool) -> Hsla { + rgb(if dark { 0x0f96d3 } else { 0x006a99 }).into() +} + +/// An inline composer chip: `inline-flex gap-[0.2em] font-medium`, icon `1em`. +fn chip(label: &'static str, icon: Icons, color: Hsla, size: f32) -> gpui::Div { + h_flex() + .gap(px(size * 0.2)) + .items_center() + .rounded(px(2.)) + .font_weight(FontWeight::MEDIUM) + .text_color(color) + .child(Icon::new(icon).size(px(size))) + .child(label) +} + +/// `PromptFontPreview`: the sentence in a `rounded-lg border bg-background px-3 py-2` box. +fn prompt_preview(family: SharedString, size: f32, cx: &App) -> AnyElement { + let theme = Tokens::of(cx); + let words = |text: &'static str| { + text.split_inclusive(' ') + .map(|word| div().child(word)) + .collect::>() + }; + h_flex() + .flex_wrap() + .items_center() + .mt_1() + .mb_2() + .px_3() + .py_2() + .rounded(px(8.)) + .border_1() + .border_color(theme.border) + .bg(theme.background) + .font_family(family) + .text_size(px(size)) + .line_height(px(size * 1.5)) + .text_color(theme.foreground) + .children(words("Use ")) + .child(chip( + "frontend-design", + Icons::Box, + skill_accent(theme.dark), + size, + )) + .children(words(" to fix the flaky test in ")) + .child(chip( + "surface.test.ts", + Icons::File, + file_accent(theme.dark), + size, + )) + .children(words(" and align the header with ")) + .child(chip( + "SettingsPanels.tsx", + Icons::File, + file_accent(theme.dark), + size, + )) + .children(words(" before shipping.")) + .into_any_element() +} + +/// The code surface (`--code-background`): `card` at 90% over `background`. +fn code_background(theme: &Tokens) -> Hsla { + theme.background.blend(theme.card.opacity(0.9)) +} + +/// `CodeFontPreview`: the unified diff of `src/formatUser.ts`. +fn diff_preview(family: SharedString, size: f32, cx: &App) -> AnyElement { + let theme = Tokens::of(cx); + let dark = theme.dark; + let pick = |light: u32, night: u32| -> Hsla { rgb(if dark { night } else { light }).into() }; + let keyword = pick(0xd52c36, 0xff678d); + let function = pick(0x7b43f8, 0x9d6afb); + let kind = pick(0x1ca1c7, 0x08c0ef); + let string = pick(0x199f43, 0x5ecc71); + let comment = pick(0x84848a, 0x84848a); + let add = pick(0x199f43, 0x5ecc71); + let del = pick(0xd52c36, 0xff6762); + let fg = theme.foreground; + let height = size * 1.5; + let code_row = |number: &'static str, background: Option, text: StyledText| { + h_flex() + .h(px(height)) + .items_center() + .when_some(background, |row, color| row.bg(color)) + .child( + div() + .w(px(size * 3.)) + .flex_shrink_0() + .pr_2() + .text_right() + .text_color(theme.muted_foreground) + .child(number), + ) + .child(div().whitespace_nowrap().child(text)) + }; + let removed = code_row( + "2", + Some(del.opacity(0.12)), + line(vec![ + seg(" ", fg), + seg("return", keyword), + seg(" user.name.", fg), + seg("toUpperCase", function), + seg("();", fg), + ]), + ); + let added = code_row( + "2", + Some(add.opacity(0.12)), + line(vec![ + seg(" ", fg), + seg("return", keyword), + seg(" ", fg), + seg("`${user.name} <${user.email}>`", string), + seg("; ", fg), + seg("// 0O 1lI", comment), + ]), + ); + v_flex() + .mt_1() + .mb_2() + .overflow_hidden() + .rounded(px(8.)) + .border_1() + .border_color(theme.border) + .bg(code_background(&theme)) + .font_family(family) + .text_size(px(size)) + .text_color(fg) + .child( + h_flex() + .h(px(size * 2.4)) + .items_center() + .gap_2() + .px_3() + .font_weight(FontWeight::MEDIUM) + .child(Icon::new(Icons::File).size(px(size))) + .child("src/formatUser.ts") + .child(div().flex_1()) + .child(div().text_color(del).child("-1")) + .child(div().text_color(add).child("+1")), + ) + .child(code_row( + "1", + None, + line(vec![ + seg("export", keyword), + seg(" ", fg), + seg("function", keyword), + seg(" ", fg), + seg("formatUser", function), + seg("(", fg), + seg("user", fg), + seg(": ", fg), + seg("User", kind), + seg(") {", fg), + ]), + )) + .child(removed) + .child(added) + .child(code_row("3", None, line(vec![seg("}", fg)]))) + .into_any_element() +} + +/// The ANSI colors the transcript uses, as a terminal theme paints them. +struct Ansi { + green: Hsla, + cyan: Hsla, + blue: Hsla, + red: Hsla, + yellow: Hsla, +} + +fn ansi(dark: bool) -> Ansi { + let pick = |light: u32, night: u32| -> Hsla { rgb(if dark { night } else { light }).into() }; + Ansi { + green: pick(0x2f7d32, 0x98c379), + cyan: pick(0x0e7490, 0x56b6c2), + blue: pick(0x2457c5, 0x61afef), + red: pick(0xc0392b, 0xe06c75), + yellow: pick(0x9a6700, 0xe5c07b), + } +} + +/// `TerminalFontPreview`: the transcript in a `rounded-lg border` box that fits its rows. +fn terminal_preview(family: SharedString, size: f32, cx: &App) -> AnyElement { + let theme = Tokens::of(cx); + let c = ansi(theme.dark); + let fg = theme.foreground; + let dim: Hsla = fg.opacity(0.6); + let prompt = || { + vec![ + seg("\u{2192}", c.green).bold(), + seg(" ", fg), + seg("t3code", c.cyan).bold(), + seg(" ", fg), + seg("git:(", c.blue).bold(), + seg("main", c.red).bold(), + seg(")", c.blue).bold(), + seg(" ", fg), + seg("\u{2717}", c.yellow).bold(), + seg(" ", fg), + ] + }; + let mut first = prompt(); + first.push(seg("vpr dev", fg)); + let mut last = prompt(); + last.push(seg(" ", fg).on(fg.opacity(0.8))); + let lines: Vec> = vec![ + first, + vec![], + vec![ + seg(" ", fg), + seg("VITE", c.green).bold(), + seg(" ", fg), + seg("v7.1.1", c.green), + seg(" ", fg), + seg("ready in", dim), + seg(" ", fg), + seg("1.24s", fg).bold(), + ], + vec![], + vec![ + seg(" ", fg), + seg("\u{2192}", c.green), + seg(" ", fg), + seg("Local:", dim), + seg(" ", fg), + seg("http://127.0.0.1:5173/", c.cyan).underline(), + ], + vec![ + seg(" ", fg), + seg("\u{2192}", c.green), + seg(" ", fg), + seg("Network:", dim), + seg(" ", fg), + seg("http://192.168.1.24:5173/", c.cyan).underline(), + ], + vec![], + vec![ + seg(" ", fg), + seg("\u{2713} 85 passed", c.green), + seg(" ", fg), + seg("\u{25b3} 2 warnings", c.yellow), + seg(" ", fg), + seg("\u{2717} 0 failed", c.red), + ], + vec![], + vec![ + seg(" ", fg), + seg(" READY ", gpui::black()).on(c.green), + seg(" ", fg), + seg("watching for changes, press", dim), + seg(" ", fg), + seg("q", fg).bold(), + seg(" ", fg), + seg("to quit", dim), + ], + vec![], + last, + ]; + debug_assert_eq!(lines.len(), TERMINAL_ROWS); + let height = (size * 1.2).ceil(); + v_flex() + .mt_1() + .mb_2() + .h(px(height * TERMINAL_ROWS as f32 + 2.)) + .overflow_hidden() + .rounded(px(8.)) + .border_1() + .border_color(theme.border) + .bg(theme.background) + .font_family(family) + .text_size(px(size)) + .text_color(fg) + .children(lines.into_iter().map(|segments| { + div() + .h(px(height)) + .whitespace_nowrap() + .child(line(segments)) + })) + .into_any_element() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn size_labels_cover_every_range() { + for slot in Slot::ALL { + let range = slot.range(); + assert_eq!(px_labels(slot).len(), range.options().count()); + assert_eq!(px_label(range.min), format!("{} px", range.min)); + assert_eq!(px_label(range.max), format!("{} px", range.max)); + } + } + + #[test] + fn titles_follow_the_separate_flag() { + assert_eq!(title(Slot::Code, false), "Monospace font"); + assert_eq!(title(Slot::Code, true), "Code font"); + assert_eq!( + description(Slot::Code, false), + "Code blocks, diffs, file previews, and the terminal." + ); + } + + #[test] + fn transcript_fills_the_box() { + assert_eq!(TERMINAL_ROWS, 12); + } +} diff --git a/apps/desktop-gpui/src/ui/settings/components.rs b/apps/desktop-gpui/src/ui/settings/components.rs index 313194abb3ca..17365d1fd20b 100644 --- a/apps/desktop-gpui/src/ui/settings/components.rs +++ b/apps/desktop-gpui/src/ui/settings/components.rs @@ -3,7 +3,12 @@ use super::tokens::Tokens; use super::*; -use gpui::{BoxShadow, ClickEvent, ElementId, FocusHandle, KeyDownEvent, point}; +use crate::settings_appearance::{Slide, slide_value}; +use gpui::{ + Bounds, BoxShadow, ClickEvent, DragMoveEvent, ElementId, FocusHandle, KeyDownEvent, + MouseButton, Pixels, canvas, point, relative, +}; +use std::cell::Cell; use std::rc::Rc; pub(super) type Toggle = Rc; @@ -98,6 +103,31 @@ pub(super) fn row( control: AnyElement, wide: bool, cx: &App, +) -> gpui::Div { + row_with(title, description, reset, control, None, wide, cx) +} + +/// A `SettingsRow` with `children`: `pt-3 pb-1` and the content under the row's columns. +pub(super) fn row_with_preview( + title: &'static str, + description: impl IntoElement, + reset: Option, + control: AnyElement, + preview: AnyElement, + wide: bool, + cx: &App, +) -> gpui::Div { + row_with(title, description, reset, control, Some(preview), wide, cx) +} + +fn row_with( + title: &'static str, + description: impl IntoElement, + reset: Option, + control: AnyElement, + preview: Option, + wide: bool, + cx: &App, ) -> gpui::Div { let theme = Tokens::of(cx); let heading = h_flex() @@ -136,7 +166,13 @@ pub(super) fn row( } else { v_flex().gap_3().child(text).child(control) }; - div().px_4().py_3().child(inner) + let framed = div().px_4(); + let framed = if preview.is_some() { + framed.pt_3().pb_1() + } else { + framed.py_3() + }; + framed.child(inner).children(preview) } /// Hover state of a `TooltipPopup side="top"`, shared by the control that opens it. @@ -315,7 +351,7 @@ pub(super) fn switch( #[derive(IntoElement)] pub(super) struct SelectTrigger { id: ElementId, - label: &'static str, + label: SharedString, width: f32, handle: FocusHandle, open: bool, @@ -339,7 +375,7 @@ impl SelectTrigger { pub(super) fn new( id: &'static str, - label: &'static str, + label: impl Into, width: f32, handle: FocusHandle, on_key: KeyHandler, @@ -347,7 +383,7 @@ impl SelectTrigger { let id = ElementId::from(id); Self { id, - label, + label: label.into(), width, handle, open: false, @@ -474,30 +510,17 @@ pub(super) fn select_menu_checked(menu: &Menu, cx: &App) -> gpui::Div { menu_items(menu, true, cx) } -fn menu_items(menu: &Menu, check: bool, cx: &App) -> gpui::Div { - let Menu { - id, - labels, - selected, - highlighted, - width, - on_pick, - on_hover, - } = menu; - let (selected, highlighted, id) = (*selected, *highlighted, *id); +/// The shell shared by the select popup and the Thread details menu: `rounded-lg border p-1` +/// on `popover`, with the deep, tight popup shadow. +pub(super) fn popup_shell(width: f32, cx: &App) -> gpui::Div { let theme = Tokens::of(cx); - let (accent, accent_foreground, chosen) = ( - theme.accent, - theme.accent_foreground, - theme.foreground.opacity(0.08), - ); let shadow = if theme.dark { (18., 44., 0.8) } else { (16., 40., 0.55) }; v_flex() - .min_w(px(*width)) + .min_w(px(width)) .p_1() .rounded(px(8.)) .border_1() @@ -510,35 +533,54 @@ fn menu_items(menu: &Menu, check: bool, cx: &App) -> gpui::Div { spread_radius: px(-18.), inset: false, }]) - .children(labels.iter().enumerate().map(|(index, label)| { - let pick = on_pick.clone(); - let hover = on_hover.clone(); - div() - .id((id, index)) - .min_h(px(28.)) - .flex() - .items_center() - .px_2() - .py_1() - .rounded(px(4.)) - .text_sm() - .cursor_pointer() - .when(index == selected, |item| item.bg(chosen)) - .when(index == highlighted, |item| { - item.bg(accent).text_color(accent_foreground) - }) - .on_hover(move |entered, _, cx| { - if *entered { - hover(index, cx); - } - }) - .on_click(move |_, window, cx| pick(index, window, cx)) - .when(check, |item| item.justify_between().gap_4()) - .child(*label) - .when(check && index == selected, |item| { - item.child(Icon::new(IconName::Check).size(px(16.))) - }) - })) +} + +fn menu_items(menu: &Menu, check: bool, cx: &App) -> gpui::Div { + let Menu { + id, + labels, + selected, + highlighted, + width, + on_pick, + on_hover, + } = menu; + let (selected, highlighted, id) = (*selected, *highlighted, *id); + let theme = Tokens::of(cx); + let (accent, accent_foreground, chosen) = ( + theme.accent, + theme.accent_foreground, + theme.foreground.opacity(0.08), + ); + popup_shell(*width, cx).children(labels.iter().enumerate().map(|(index, label)| { + let pick = on_pick.clone(); + let hover = on_hover.clone(); + div() + .id((id, index)) + .min_h(px(28.)) + .flex() + .items_center() + .px_2() + .py_1() + .rounded(px(4.)) + .text_sm() + .cursor_pointer() + .when(index == selected, |item| item.bg(chosen)) + .when(index == highlighted, |item| { + item.bg(accent).text_color(accent_foreground) + }) + .on_hover(move |entered, _, cx| { + if *entered { + hover(index, cx); + } + }) + .on_click(move |_, window, cx| pick(index, window, cx)) + .when(check, |item| item.justify_between().gap_4()) + .child(*label) + .when(check && index == selected, |item| { + item.child(Icon::new(IconName::Check).size(px(16.))) + }) + })) } /// `Button variant="ghost"` (button.tsx:59-60): `rounded-[--control-radius]` (8px), a transparent @@ -644,3 +686,198 @@ pub(super) fn disclosure( .when(open, |disclosure| disclosure.child(panel)) .into_any_element() } + +/// `Button variant="outline" size="xs"` at the desktop size: `h-6 gap-1 px-[7px] text-xs`, the +/// icon 14px in `--contrast-muted-foreground`. The caller adds the icon and label as children. +pub(super) fn outline_button(id: &'static str, cx: &App) -> gpui::Stateful { + let theme = Tokens::of(cx); + let (background, hover) = if theme.dark { + (theme.input.opacity(0.32), theme.input.opacity(0.64)) + } else { + (theme.popover, theme.accent.opacity(0.5)) + }; + div() + .id(id) + .flex() + .items_center() + .gap_1() + .h(px(24.)) + .px(px(7.)) + .flex_shrink_0() + .rounded(px(8.)) + .border_1() + .border_color(theme.input) + .bg(background) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(theme.foreground) + .cursor_pointer() + .hover(move |style| style.bg(hover)) +} + +pub(super) type Change = Rc; + +/// The drag payload of a slider; the id keeps one slider's drag from moving another. +#[derive(Clone)] +struct SliderDrag(ElementId); + +impl Render for SliderDrag { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + +/// Thumb diameter; the thumb centre travels the track minus this. +const THUMB: f32 = 16.; + +/// The value under window x for a slider laid out in `bounds`, with the thumb centre running from +/// 8px in to 8px short of the far edge (index.css:963-1077). +fn slider_value_at(range: Slide, bounds: Bounds, x: Pixels) -> u32 { + let travel = (f32::from(bounds.size.width) - THUMB).max(1.); + let ratio = (f32::from(x - bounds.origin.x) - THUMB / 2.) / travel; + slide_value(range, ratio) +} + +/// The `Slider` styling of index.css:963-1077: a 24px row holding a 6px track (`muted-foreground` +/// at 22% under a `primary` fill to the thumb centre, with a 1px border), and a 16px thumb with a +/// 2px `primary` ring on `background`. Click or drag to set, arrow keys step, Home and End jump. +pub(super) fn slider( + id: &'static str, + range: Slide, + value: u32, + on_change: Change, + window: &mut Window, + cx: &mut App, +) -> AnyElement { + let key = ElementId::from(id); + let handle = tab_stop(&key, window, cx); + let visible = focus_visible(&handle, window); + let measured = window + .use_keyed_state(ElementId::from((key.clone(), "bounds")), cx, |_, _| { + Rc::new(Cell::new(Bounds::::default())) + }) + .read(cx) + .clone(); + let theme = Tokens::of(cx); + let ratio = value.saturating_sub(range.min) as f32 / (range.max - range.min) as f32; + let ratio = ratio.clamp(0., 1.); + let thumb_shadow = if visible { + vec![ + BoxShadow { + color: theme.background, + offset: point(px(0.), px(0.)), + blur_radius: px(0.), + spread_radius: px(1.), + inset: true, + }, + BoxShadow { + color: theme.ring, + offset: point(px(0.), px(0.)), + blur_radius: px(0.), + spread_radius: px(3.), + inset: true, + }, + ] + } else { + vec![BoxShadow { + color: theme.foreground.opacity(0.12), + offset: point(px(0.), px(1.)), + blur_radius: px(2.), + spread_radius: px(0.), + inset: false, + }] + }; + let track = div() + .absolute() + .left_0() + .right_0() + .top(px(9.)) + .h(px(6.)) + .rounded_full() + .overflow_hidden() + .bg(theme.muted_foreground.opacity(0.22)) + .child( + h_flex() + .size_full() + .child(div().w(px(THUMB / 2.)).h_full().bg(theme.primary)) + .child( + div() + .flex_1() + .h_full() + .child(div().w(relative(ratio)).h_full().bg(theme.primary)), + ), + ) + .child( + div() + .absolute() + .inset_0() + .rounded_full() + .border_1() + .border_color(theme.border.opacity(0.55)), + ); + let thumb = div() + .absolute() + .top(px(4.)) + .bottom(px(4.)) + .left(px(THUMB / 2.)) + .right(px(THUMB / 2.)) + .child( + div() + .absolute() + .left(relative(ratio)) + .ml(px(-THUMB / 2.)) + .size(px(THUMB)) + .rounded_full() + .border_2() + .border_color(theme.primary) + .bg(theme.background) + .shadow(thumb_shadow), + ); + let (down_cell, down_change) = (measured.clone(), on_change.clone()); + let (drag_id, drag_change) = (key.clone(), on_change.clone()); + let key_change = on_change; + let focus = handle.clone(); + div() + .id(key.clone()) + .track_focus(&handle) + .relative() + .w_full() + .h(px(24.)) + .cursor_pointer() + .on_mouse_down(MouseButton::Left, move |event, window, cx| { + window.focus(&focus, cx); + let value = slider_value_at(range, down_cell.get(), event.position.x); + down_change(value, window, cx); + }) + .on_drag(SliderDrag(key), |drag, _, _, cx| { + cx.stop_propagation(); + cx.new(|_| drag.clone()) + }) + .on_drag_move(move |event: &DragMoveEvent, window, cx| { + if event.drag(cx).0 != drag_id { + return; + } + let value = slider_value_at(range, event.bounds, event.event.position.x); + drag_change(value, window, cx); + }) + .on_key_down(move |event: &KeyDownEvent, window, cx| { + let step = range.step; + let next = match event.keystroke.key.as_str() { + "left" | "down" => value.saturating_sub(step).max(range.min), + "right" | "up" => (value + step).min(range.max), + "home" => range.min, + "end" => range.max, + _ => return, + }; + cx.stop_propagation(); + key_change(next, window, cx); + }) + .child( + canvas(move |bounds, _, _| measured.set(bounds), |_, _, _, _| {}) + .absolute() + .inset_0(), + ) + .child(track) + .child(thumb) + .into_any_element() +} diff --git a/apps/desktop-gpui/src/ui/settings/scope_pickers.rs b/apps/desktop-gpui/src/ui/settings/scope_pickers.rs index 291b73933d5c..303579b4e9db 100644 --- a/apps/desktop-gpui/src/ui/settings/scope_pickers.rs +++ b/apps/desktop-gpui/src/ui/settings/scope_pickers.rs @@ -26,6 +26,16 @@ pub(super) struct Picker { pub query: String, pub on_open: OpenHandler, pub on_pick: ChoiceHandler, + pub style: PickerStyle, +} + +/// How a picker differs from the scope pickers: the Typography font picker (`FontFamilyPicker`, +/// `w-44` trigger, `w-72` popup aligned to the end) is a virtualized combobox whose rows are set in +/// their own family, with a "default" tag on the `all` entry that hides while searching. +#[derive(Default, Clone, Copy)] +pub(super) struct PickerStyle { + pub trigger_width: Option, + pub fonts: bool, } /// `Button variant="outline" size="sm"` with `min-w-0 max-w-72`. @@ -37,6 +47,7 @@ struct Trigger { handle: FocusHandle, open: bool, on_open: OpenHandler, + width: Option, } impl Selectable for Trigger { @@ -77,6 +88,9 @@ impl RenderOnce for Trigger { .track_focus(&self.handle) .min_w_0() .max_w(px(288.)) + .when_some(self.width, |trigger, width| { + trigger.w(px(width)).justify_between() + }) .min_h(px(28.)) .items_center() .gap(px(6.)) @@ -140,6 +154,14 @@ fn icon(kind: &ChoiceIcon, cx: &App) -> Option { } } +/// What a font row adds: its own family and the "default" tag. +#[derive(Default, Clone)] +struct RowStyle { + font: Option, + default_tag: bool, + height: Option, +} + /// `ScopeOptionLabel` plus the selected check (`CheckIcon size-3.5`). fn row( index: usize, @@ -147,6 +169,7 @@ fn row( choice: &Choice, picked: bool, on_pick: &ChoiceHandler, + style: RowStyle, cx: &App, ) -> gpui::Stateful { let theme = Tokens::of(cx); @@ -159,7 +182,7 @@ fn row( let on_pick = on_pick.clone(); h_flex() .id((id, index)) - .min_h(px(28.)) + .min_h(px(style.height.unwrap_or(28.))) .items_center() .gap_2() .px_2() @@ -174,7 +197,14 @@ fn row( v_flex() .min_w_0() .flex_1() - .child(div().truncate().child(choice.label.clone())) + .child( + div() + .truncate() + .when_some(style.font.clone(), |label, family| { + label.font_family(family) + }) + .child(choice.label.clone()), + ) .when(!choice.secondary.is_empty(), |text| { text.child( div() @@ -185,6 +215,15 @@ fn row( ) }), ) + .when(style.default_tag, |row| { + row.child( + div() + .flex_shrink_0() + .text_size(px(10.)) + .text_color(muted.opacity(0.6)) + .child("default"), + ) + }) .when(choice.offline, |row| { row.child( div() @@ -228,6 +267,42 @@ fn shell(width: f32, cx: &App) -> gpui::Div { }]) } +/// The virtualized font rows: a `uniform_list` of 30px rows, at most ten tall. +fn font_list(spec: &Picker, shown: &[&Choice], cx: &App) -> AnyElement { + const ROW: f32 = 30.; + let rows: Rc> = Rc::new(shown.iter().map(|c| (*c).clone()).collect()); + let height = (rows.len() as f32 * ROW).min(288.); + let (id, selected, on_pick) = (spec.id, spec.selected.clone(), spec.on_pick.clone()); + let default = spec.all.as_ref().map(|choice| choice.value.clone()); + let count = rows.len(); + let _ = cx; + gpui::uniform_list(ElementId::from((id, 2usize)), count, move |range, _, cx| { + range + .map(|index| { + let choice = &rows[index]; + let is_default = default.as_deref() == Some(choice.value.as_str()); + let style = RowStyle { + font: (!is_default).then(|| SharedString::from(choice.label.clone())), + default_tag: is_default, + height: Some(ROW), + }; + row( + index, + id, + choice, + choice.value == selected, + &on_pick, + style, + cx, + ) + }) + .collect::>() + }) + .h(px(height)) + .mt_1() + .into_any_element() +} + pub(super) fn picker(spec: Picker, window: &mut Window, cx: &mut App) -> AnyElement { let id = ElementId::from(spec.id); let handle = tab_stop(&id, window, cx); @@ -238,12 +313,18 @@ pub(super) fn picker(spec: Picker, window: &mut Window, cx: &mut App) -> AnyElem handle, open: spec.open, on_open: spec.on_open.clone(), + width: spec.style.trigger_width, }; let on_change = spec.on_open.clone(); - let combobox = labs::searchable(spec.choices.len()); + let fonts = spec.style.fonts; + let combobox = fonts || labs::searchable(spec.choices.len()); let popover_id = ElementId::from((spec.id, 1usize)); Popover::new(popover_id) - .anchor(Anchor::TopLeft) + .anchor(if fonts { + Anchor::TopRight + } else { + Anchor::TopLeft + }) .offset(px(4.)) .open(spec.open) .p_0() @@ -254,7 +335,7 @@ pub(super) fn picker(spec: Picker, window: &mut Window, cx: &mut App) -> AnyElem let mut all: Vec = spec.all.iter().cloned().collect(); all.extend(spec.choices.iter().cloned()); let theme = Tokens::of(cx); - let width = if combobox { 320. } else { 288. }; + let width = if fonts || !combobox { 288. } else { 320. }; let rows = |items: Vec<&Choice>| -> Vec { let mut out = Vec::new(); let has_all = spec.all.is_some(); @@ -266,6 +347,7 @@ pub(super) fn picker(spec: Picker, window: &mut Window, cx: &mut App) -> AnyElem choice, choice.value == spec.selected, &spec.on_pick, + RowStyle::default(), cx, ) .into_any_element(), @@ -287,8 +369,41 @@ pub(super) fn picker(spec: Picker, window: &mut Window, cx: &mut App) -> AnyElem if !combobox { return shell(width, cx).children(rows(all.iter().collect())); } - let shown = labs::filter(&all, &spec.query); + let shown = if fonts { + // The default entry is offered only before any search (`query.length === 0`). + let named: Vec = spec.choices.clone(); + if spec.query.trim().is_empty() { + all.clone() + } else { + labs::filter(&named, &spec.query) + .into_iter() + .cloned() + .collect() + } + } else { + labs::filter(&all, &spec.query) + .into_iter() + .cloned() + .collect() + }; + let shown: Vec<&Choice> = shown.iter().collect(); let empty = shown.is_empty(); + let list = if fonts { + font_list(&spec, &shown, cx) + } else { + v_flex() + .id((spec.id, 2usize)) + .max_h(px(288.)) + .overflow_y_scroll() + .pt_1() + .children(rows(shown)) + .into_any_element() + }; + let empty_text = if fonts { + "No fonts found." + } else { + "No matches found." + }; shell(width, cx) .child( div() @@ -303,24 +418,17 @@ pub(super) fn picker(spec: Picker, window: &mut Window, cx: &mut App) -> AnyElem .prefix(Icon::new(IconName::Search).size(px(14.))), ), ) - .child( - v_flex() - .id((spec.id, 2usize)) - .max_h(px(288.)) - .overflow_y_scroll() - .pt_1() - .children(rows(shown)) - .when(empty, |list| { - list.child( - div() - .py_2() - .text_center() - .text_sm() - .text_color(theme.muted_foreground) - .child("No matches found."), - ) - }), - ) + .child(list) + .when(empty, |shell| { + shell.child( + div() + .py_2() + .text_center() + .text_sm() + .text_color(theme.muted_foreground) + .child(empty_text), + ) + }) }) .into_any_element() } @@ -429,6 +537,7 @@ impl ScopeBar { .as_ref() .and_then(|key| self.data.projects.iter().find(|p| &p.key == key)); let spec = Picker { + style: PickerStyle::default(), id: "settings-scope-project", label: labs::project_label(&self.search, &self.data), icon: selected.map(|p| ChoiceIcon::Project { @@ -474,6 +583,7 @@ impl ScopeBar { .as_ref() .and_then(|id| self.data.environments.iter().find(|e| &e.id == id)); let spec = Picker { + style: PickerStyle::default(), id: "settings-scope-environment", label: labs::environment_trigger_label(&self.search, &self.data), icon: selected.map(|e| ChoiceIcon::Machine(e.machine)), diff --git a/apps/desktop-gpui/src/ui/settings/tokens.rs b/apps/desktop-gpui/src/ui/settings/tokens.rs index ffa430b077de..cdd460463375 100644 --- a/apps/desktop-gpui/src/ui/settings/tokens.rs +++ b/apps/desktop-gpui/src/ui/settings/tokens.rs @@ -8,13 +8,14 @@ use gpui::{App, Hsla, rgb, white}; use gpui_component::ActiveTheme as _; #[derive(Clone, Copy)] -pub(super) struct Tokens { +pub(crate) struct Tokens { pub dark: bool, pub background: Hsla, pub foreground: Hsla, pub card: Hsla, pub popover: Hsla, pub primary: Hsla, + pub muted: Hsla, pub muted_foreground: Hsla, pub accent: Hsla, pub accent_foreground: Hsla, @@ -38,7 +39,7 @@ fn mix(a: u32, b: u32, share_of_a: f32) -> Hsla { } impl Tokens { - pub(super) fn of(cx: &App) -> Self { + pub(crate) fn of(cx: &App) -> Self { if cx.theme().is_dark() { Self::dark() } else { @@ -57,6 +58,7 @@ impl Tokens { card, popover: card, primary: rgb(0x346bf1).into(), + muted: white().opacity(0.03), muted_foreground: rgb(0xa3a3a3).into(), accent: white().opacity(0.04), accent_foreground: rgb(0xf5f5f5).into(), @@ -80,6 +82,7 @@ impl Tokens { card: rgb(0xffffff).into(), popover: rgb(0xffffff).into(), primary: rgb(0x1b4ed8).into(), + muted: rgb(0xfafafa).into(), muted_foreground: rgb(0x71717a).into(), accent: rgb(0xf4f4f5).into(), accent_foreground: rgb(0x18181b).into(), diff --git a/apps/desktop-gpui/src/ui/workspace_menu.rs b/apps/desktop-gpui/src/ui/workspace_menu.rs new file mode 100644 index 000000000000..7ca752cbd0f2 --- /dev/null +++ b/apps/desktop-gpui/src/ui/workspace_menu.rs @@ -0,0 +1,126 @@ +//! The web's `BranchToolbarEnvModeSelector`: the Workspace select, in the shared composer menu +//! look (`composer_menu`), with `PreviousWorktreeItemContent.tsx` for the Previous worktree row. + +use super::composer_menu::{self, Row, RowState}; +use super::settings::tokens::Tokens; +use super::*; +use crate::workspace::{Choice, Draft, Glyph, Seed}; + +pub(super) fn icon(glyph: Glyph) -> Icons { + match glyph { + Glyph::Folder => Icons::Folder, + Glyph::FolderGit => Icons::FolderGit, + Glyph::FolderGit2 => Icons::FolderGit2, + Glyph::History => Icons::RotateCcwClock, + } +} + +/// What the menu needs from the draft it edits. +pub(super) struct Menu { + pub view: Entity, + pub draft: Draft, + pub previous: Option, + pub highlight: usize, +} + +pub(super) fn popover(menu: Menu, open: bool, shortcut: &str) -> AnyElement { + let (glyph, label) = menu.draft.trigger(); + let view = menu.view.clone(); + Popover::new("workspace") + .anchor(Anchor::BottomLeft) + .offset(px(4.)) + .open(open) + .p_0() + .appearance(false) + .on_open_change(move |open, window, cx| { + view.update(cx, |this, cx| this.set_workspace_open(*open, window, cx)); + }) + .trigger(composer_menu::Trigger { + id: "workspace", + icon: icon(glyph), + label: label.into(), + tip: strip::tooltip_text(label, Some(shortcut)), + open, + }) + .content(move |_, _, cx| content(&menu, cx)) + .into_any_element() +} + +/// `w-[min(21rem,...)]` once Previous worktree is offered. +fn content(menu: &Menu, cx: &App) -> gpui::Div { + let selected = menu.draft.selected(); + let rows = Draft::rows(menu.previous.is_some()) + .into_iter() + .enumerate() + .map(|(index, choice)| { + let (click_view, hover_view) = (menu.view.clone(), menu.view.clone()); + let body = match (choice, &menu.previous) { + (Choice::Previous, Some(seed)) => previous_body(seed, cx).into_any_element(), + _ => composer_menu::plain_body( + icon(menu.draft.glyph(choice)), + menu.draft.label(choice), + cx, + ) + .into_any_element(), + }; + Row { + id: ("workspace-row", index), + state: RowState { + selected: choice == selected, + lit: index == menu.highlight, + ..RowState::default() + }, + body, + on_hover: Box::new(move |cx| { + hover_view.update(cx, |this, cx| this.highlight_workspace(index, cx)); + }), + on_click: Box::new(move |window, cx| { + click_view.update(cx, |this, cx| this.pick_workspace(choice, window, cx)); + }), + } + }) + .collect(); + composer_menu::popup( + "Workspace", + menu.previous.is_some().then(|| px(338.)), + rows, + cx, + ) +} + +/// `PreviousWorktreeItemContent`: the history icon at `mt-1`, the label, and the branch beneath +/// it in `text-xs text-muted-foreground`. +fn previous_body(seed: &Seed, cx: &App) -> gpui::Div { + let muted = Tokens::of(cx).muted_foreground; + h_flex() + .min_w_0() + .items_start() + .gap(px(6.)) + .child( + div() + .mt(px(4.)) + .text_color(muted) + .child(Icon::new(icon(Glyph::History)).size(px(12.))), + ) + .child( + v_flex() + .min_w_0() + .child( + div() + .whitespace_nowrap() + .child(Draft::default().label(Choice::Previous)), + ) + .children(seed.branch.as_deref().map(|branch| { + div() + .min_w_0() + .text_xs() + .line_height(px(16.)) + .text_color(muted) + .whitespace_nowrap() + .child(middle_truncate(branch, BRANCH_BUDGET)) + })), + ) +} + +/// The characters of a branch that fit the `w-[21rem]` row at `text-xs`. +const BRANCH_BUDGET: usize = 44; diff --git a/apps/desktop-gpui/src/workspace.rs b/apps/desktop-gpui/src/workspace.rs new file mode 100644 index 000000000000..3c0979f6d9c7 --- /dev/null +++ b/apps/desktop-gpui/src/workspace.rs @@ -0,0 +1,281 @@ +//! What the composer's Workspace menu offers and does: `BranchToolbar.logic.ts` (the previous +//! worktree seed and the labels), `BranchToolbarEnvModeSelector.tsx` (the rows and the trigger) +//! and `useComposerSelection.ts` `onEnvModeChange` (what each row changes in the draft). + +use crate::strip::EnvMode; + +/// `PreviousWorktreeSeed`: the worktree the Previous worktree row reuses, and its branch. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Seed { + pub branch: Option, + pub worktree_path: String, +} + +/// The facts about one thread `resolvePreviousWorktreeSeed` reads. Archived threads are not in +/// the shell, which is how the web's `archivedAt` filter holds here. +#[derive(Clone, Copy, Debug)] +pub struct Thread<'a> { + pub branch: Option<&'a str>, + pub worktree_path: Option<&'a str>, + /// `None` when the timestamp does not parse, which skips the thread. + pub updated_at_ms: Option, +} + +/// `resolvePreviousWorktreeSeed`: the most recently updated thread's worktree other than the +/// draft's own; on a tie the earlier thread wins. +pub fn previous_seed<'a>( + threads: impl IntoIterator>, + current_worktree: Option<&str>, +) -> Option { + let mut latest: Option<(Seed, i64)> = None; + for thread in threads { + let Some(path) = thread.worktree_path.filter(|path| !path.is_empty()) else { + continue; + }; + if Some(path) == current_worktree { + continue; + } + let Some(updated) = thread.updated_at_ms else { + continue; + }; + if latest.as_ref().is_none_or(|(_, at)| updated > *at) { + latest = Some(( + Seed { + branch: thread.branch.map(str::to_owned), + worktree_path: path.to_owned(), + }, + updated, + )); + } + } + latest.map(|(seed, _)| seed) +} + +/// A row of the menu. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Choice { + Current, + NewWorktree, + Previous, +} + +/// The icons the menu and its trigger use (lucide `Folder`, `FolderGit`, `FolderGit2`, `History`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Glyph { + Folder, + FolderGit, + FolderGit2, + History, +} + +/// The workspace a draft is set up with: the mode, and the worktree it already has, which only +/// the Previous worktree row sets. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Draft { + pub mode: EnvMode, + pub worktree: Option, +} + +impl Draft { + /// The menu's rows, in the web's order; Previous worktree only when there is one to offer. + pub fn rows(has_previous: bool) -> Vec { + let mut rows = vec![Choice::Current, Choice::NewWorktree]; + if has_previous { + rows.push(Choice::Previous); + } + rows + } + + /// The row the select's value names: the effective mode, never Previous worktree. + pub fn selected(&self) -> Choice { + match self.mode { + EnvMode::Worktree => Choice::NewWorktree, + EnvMode::Local => Choice::Current, + } + } + + /// `resolveCurrentWorkspaceLabel`. + pub fn current_label(&self) -> &'static str { + if self.worktree.is_some() { + "Current worktree" + } else { + EnvMode::Local.label() + } + } + + /// The label of a row, other than Previous worktree's second line. + pub fn label(&self, choice: Choice) -> &'static str { + match choice { + Choice::Current => self.current_label(), + Choice::NewWorktree => EnvMode::Worktree.label(), + Choice::Previous => "Previous worktree", + } + } + + pub fn glyph(&self, choice: Choice) -> Glyph { + match choice { + Choice::Current if self.worktree.is_some() => Glyph::FolderGit, + Choice::Current => Glyph::Folder, + Choice::NewWorktree => Glyph::FolderGit2, + Choice::Previous => Glyph::History, + } + } + + /// What the trigger shows: the effective mode's label and icon. + pub fn trigger(&self) -> (Glyph, &'static str) { + let shown = self.selected(); + (self.glyph(shown), self.label(shown)) + } + + /// The draft after a row is chosen (`onEnvModeChange`, `onUsePreviousWorktree`). A new + /// worktree drops the draft's worktree, the current checkout keeps it, and Previous worktree + /// adopts the seed in worktree mode; without a seed nothing changes. + pub fn choose(&self, choice: Choice, previous: Option<&Seed>) -> Self { + match choice { + Choice::Current => Self { + mode: EnvMode::Local, + worktree: self.worktree.clone(), + }, + Choice::NewWorktree => Self { + mode: EnvMode::Worktree, + worktree: None, + }, + Choice::Previous => match previous { + Some(seed) => Self { + mode: EnvMode::Worktree, + worktree: Some(seed.clone()), + }, + None => self.clone(), + }, + } + } + + /// The ref the branch control names while the draft has a worktree: its branch, as + /// `resolveBranchTriggerLabel` returns it plain. + pub fn branch_label(&self) -> Option { + let seed = self.worktree.as_ref()?; + Some( + seed.branch + .clone() + .unwrap_or_else(|| "Select ref".to_owned()), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn thread<'a>(branch: &'a str, path: Option<&'a str>, at: Option) -> Thread<'a> { + Thread { + branch: Some(branch), + worktree_path: path, + updated_at_ms: at, + } + } + + fn seed(branch: Option<&str>, path: &str) -> Seed { + Seed { + branch: branch.map(str::to_owned), + worktree_path: path.to_owned(), + } + } + + #[test] + fn the_seed_is_the_latest_other_worktree() { + let threads = [ + thread("a", Some("/w/a"), Some(10)), + thread("b", Some("/w/b"), Some(30)), + thread("c", Some("/w/c"), Some(20)), + thread("root", None, Some(99)), + thread("blank", Some(""), Some(99)), + thread("bad-time", Some("/w/d"), None), + ]; + assert_eq!(previous_seed(threads, None), Some(seed(Some("b"), "/w/b"))); + assert_eq!( + previous_seed(threads, Some("/w/b")), + Some(seed(Some("c"), "/w/c")) + ); + assert_eq!(previous_seed(threads[3..].iter().copied(), None), None); + assert_eq!(previous_seed([], None), None); + } + + #[test] + fn a_tie_keeps_the_earlier_thread() { + let threads = [ + thread("first", Some("/w/1"), Some(5)), + thread("second", Some("/w/2"), Some(5)), + ]; + assert_eq!( + previous_seed(threads, None), + Some(seed(Some("first"), "/w/1")) + ); + } + + #[test] + fn a_seed_without_a_branch_is_still_offered() { + let threads = [Thread { + branch: None, + worktree_path: Some("/w/a"), + updated_at_ms: Some(1), + }]; + assert_eq!(previous_seed(threads, None), Some(seed(None, "/w/a"))); + } + + #[test] + fn rows_offer_previous_worktree_last_and_only_with_a_seed() { + assert_eq!(Draft::rows(false), [Choice::Current, Choice::NewWorktree]); + assert_eq!( + Draft::rows(true), + [Choice::Current, Choice::NewWorktree, Choice::Previous] + ); + } + + #[test] + fn choosing_follows_on_env_mode_change() { + let previous = seed(Some("feature"), "/w/a"); + let fresh = Draft::default(); + assert_eq!(fresh.trigger(), (Glyph::Folder, "Current checkout")); + + let reused = fresh.choose(Choice::Previous, Some(&previous)); + assert_eq!(reused.mode, EnvMode::Worktree); + assert_eq!(reused.worktree, Some(previous.clone())); + // The select's value is the mode, so the trigger still reads New worktree. + assert_eq!(reused.trigger(), (Glyph::FolderGit2, "New worktree")); + assert_eq!(reused.branch_label(), Some("feature".to_owned())); + + // The current checkout keeps the worktree and renames itself. + let current = reused.choose(Choice::Current, None); + assert_eq!(current.mode, EnvMode::Local); + assert_eq!(current.worktree, Some(previous)); + assert_eq!(current.trigger(), (Glyph::FolderGit, "Current worktree")); + assert_eq!(current.label(Choice::Current), "Current worktree"); + assert_eq!(current.glyph(Choice::Current), Glyph::FolderGit); + + // A new worktree drops it again. + let cleared = current.choose(Choice::NewWorktree, None); + assert_eq!( + cleared, + Draft { + mode: EnvMode::Worktree, + worktree: None + } + ); + assert_eq!(cleared.branch_label(), None); + } + + #[test] + fn previous_worktree_without_a_seed_changes_nothing() { + let draft = Draft::default(); + assert_eq!(draft.choose(Choice::Previous, None), draft); + } + + #[test] + fn a_seedless_branch_reads_as_an_unselected_ref() { + let draft = Draft { + mode: EnvMode::Local, + worktree: Some(seed(None, "/w/a")), + }; + assert_eq!(draft.branch_label(), Some("Select ref".to_owned())); + } +} From 63e61f6b68ac61f0245dd04b43803d406ee19a7e Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 9 Nov 2026 04:50:53 -0500 Subject: [PATCH 68/82] feat(desktop-gpui): source control settings, add-to-machine dialog, model picker polish Ports the Source control settings tab with host credential panels, a shared secret-source control, and value-grouped scope inheritance. Adds the add to another machine dialog and lists every environment in Run on without a project. Model picker gains show more/less, a scrollable rail with tooltips, and the opaque popover fallback. Theme orbs are rasterized for clean edges, and only the clicked permissions popover opens. --- apps/desktop-gpui/Cargo.lock | 2 + apps/desktop-gpui/Cargo.toml | 2 + apps/desktop-gpui/src/assets.rs | 90 + apps/desktop-gpui/src/clone_flow.rs | 1004 +++++++++++ apps/desktop-gpui/src/connection.rs | 56 +- apps/desktop-gpui/src/environments.rs | 29 +- apps/desktop-gpui/src/main.rs | 4 + apps/desktop-gpui/src/projects.rs | 31 + apps/desktop-gpui/src/providers.rs | 17 +- apps/desktop-gpui/src/rpc.rs | 47 + apps/desktop-gpui/src/secret_source.rs | 236 +++ apps/desktop-gpui/src/settings.rs | 1 + apps/desktop-gpui/src/settings_appearance.rs | 145 +- apps/desktop-gpui/src/settings_labs.rs | 1 + .../src/settings_source_control.rs | 1422 ++++++++++++++++ .../aws_secrets_manager.svg | 1 + .../src/source-control-icons/azure-devops.svg | 1 + .../src/source-control-icons/bitbucket.svg | 1 + .../src/source-control-icons/forgejo.svg | 1 + .../src/source-control-icons/git.svg | 1 + .../src/source-control-icons/gitcafe.svg | 1 + .../src/source-control-icons/github-dark.svg | 1 + .../src/source-control-icons/github-light.svg | 1 + .../src/source-control-icons/gitlab.svg | 1 + .../src/source-control-icons/jj.svg | 1 + .../src/source-control-icons/one_password.svg | 1 + .../src/source-control-icons/openbao.svg | 1 + apps/desktop-gpui/src/status.rs | 2 +- apps/desktop-gpui/src/ui.rs | 179 +- apps/desktop-gpui/src/ui/add_to_machine.rs | 1363 +++++++++++++++ apps/desktop-gpui/src/ui/composer_menu.rs | 26 +- apps/desktop-gpui/src/ui/model_popover.rs | 249 ++- apps/desktop-gpui/src/ui/run_on_menu.rs | 63 +- apps/desktop-gpui/src/ui/settings.rs | 47 +- .../src/ui/settings/appearance_panel.rs | 5 +- .../src/ui/settings/appearance_themes.rs | 363 ++-- .../src/ui/settings/components.rs | 77 +- .../src/ui/settings/devices_panel.rs | 21 +- .../src/ui/settings/devices_panel/editors.rs | 56 +- .../src/ui/settings/devices_panel/widgets.rs | 14 +- .../src/ui/settings/scope_inheritance.rs | 274 ++- .../src/ui/settings/secret_field.rs | 630 +++++++ .../src/ui/settings/source_control_panel.rs | 1480 +++++++++++++++++ .../ui/settings/source_control_panel/hosts.rs | 1258 ++++++++++++++ apps/desktop-gpui/src/ui/settings/tokens.rs | 2 +- apps/desktop-gpui/src/ui/workspace_menu.rs | 2 +- 46 files changed, 8841 insertions(+), 369 deletions(-) create mode 100644 apps/desktop-gpui/src/clone_flow.rs create mode 100644 apps/desktop-gpui/src/secret_source.rs create mode 100644 apps/desktop-gpui/src/settings_source_control.rs create mode 100644 apps/desktop-gpui/src/source-control-icons/aws_secrets_manager.svg create mode 100644 apps/desktop-gpui/src/source-control-icons/azure-devops.svg create mode 100644 apps/desktop-gpui/src/source-control-icons/bitbucket.svg create mode 100644 apps/desktop-gpui/src/source-control-icons/forgejo.svg create mode 100644 apps/desktop-gpui/src/source-control-icons/git.svg create mode 100644 apps/desktop-gpui/src/source-control-icons/gitcafe.svg create mode 100644 apps/desktop-gpui/src/source-control-icons/github-dark.svg create mode 100644 apps/desktop-gpui/src/source-control-icons/github-light.svg create mode 100644 apps/desktop-gpui/src/source-control-icons/gitlab.svg create mode 100644 apps/desktop-gpui/src/source-control-icons/jj.svg create mode 100644 apps/desktop-gpui/src/source-control-icons/one_password.svg create mode 100644 apps/desktop-gpui/src/source-control-icons/openbao.svg create mode 100644 apps/desktop-gpui/src/ui/add_to_machine.rs create mode 100644 apps/desktop-gpui/src/ui/settings/secret_field.rs create mode 100644 apps/desktop-gpui/src/ui/settings/source_control_panel.rs create mode 100644 apps/desktop-gpui/src/ui/settings/source_control_panel/hosts.rs diff --git a/apps/desktop-gpui/Cargo.lock b/apps/desktop-gpui/Cargo.lock index 93798877e7ce..edfb1c4f98a1 100644 --- a/apps/desktop-gpui/Cargo.lock +++ b/apps/desktop-gpui/Cargo.lock @@ -6269,6 +6269,7 @@ dependencies = [ "hmac 0.13.0", "http-body", "http-body-util", + "image", "libc", "markdown", "prettyplease", @@ -6278,6 +6279,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.1", + "smallvec", "snap", "syn 3.0.9", "tokio", diff --git a/apps/desktop-gpui/Cargo.toml b/apps/desktop-gpui/Cargo.toml index 75d2ca22ddd6..2a8afe2683b4 100644 --- a/apps/desktop-gpui/Cargo.toml +++ b/apps/desktop-gpui/Cargo.toml @@ -18,12 +18,14 @@ gpui_platform = { package = "gpui-pre-platform", version = "=0.3.11", features = hex = "0.4.3" hmac = "0.13.0" http-body = "1" +image = { version = "0.25.10", default-features = false } libc = "0.2.191" markdown = "1.0.0" reqwest = { version = "0.13.5", default-features = false, features = ["form", "json", "rustls"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.154" sha2 = "0.11.1" +smallvec = "1.16.4" snap = "1" tokio = { version = "1.54.1", features = [ "rt-multi-thread", diff --git a/apps/desktop-gpui/src/assets.rs b/apps/desktop-gpui/src/assets.rs index a9fa362e68da..24a588381764 100644 --- a/apps/desktop-gpui/src/assets.rs +++ b/apps/desktop-gpui/src/assets.rs @@ -7,6 +7,7 @@ gpui_kit_assets::icon_assets!( ExtraIcons, [ Paperclip, + Type, Box, Pin, PinOff, @@ -299,6 +300,86 @@ pub fn chip_icon(kind: ChipKind) -> Option<&'static str> { }) } +/// The version control and hosting marks of `SourceControlSettings` (`VcsIcon`, `SourceControlProviderIcon`). +const SOURCE_CONTROL_ICONS: &[(&str, &[u8])] = &[ + ( + "source-control/github-dark.svg", + include_bytes!("source-control-icons/github-dark.svg") as &[u8], + ), + ( + "source-control/github-light.svg", + include_bytes!("source-control-icons/github-light.svg") as &[u8], + ), + ( + "source-control/gitlab.svg", + include_bytes!("source-control-icons/gitlab.svg") as &[u8], + ), + ( + "source-control/gitcafe.svg", + include_bytes!("source-control-icons/gitcafe.svg") as &[u8], + ), + ( + "source-control/forgejo.svg", + include_bytes!("source-control-icons/forgejo.svg") as &[u8], + ), + ( + "source-control/bitbucket.svg", + include_bytes!("source-control-icons/bitbucket.svg") as &[u8], + ), + ( + "source-control/azure-devops.svg", + include_bytes!("source-control-icons/azure-devops.svg") as &[u8], + ), + ( + "source-control/git.svg", + include_bytes!("source-control-icons/git.svg") as &[u8], + ), + ( + "source-control/jj.svg", + include_bytes!("source-control-icons/jj.svg") as &[u8], + ), +]; + +/// The marks of the secret providers (`SecretProviderKindIcon`). +const SECRET_ICONS: &[(&str, &[u8])] = &[ + ( + "secret/one_password.svg", + include_bytes!("source-control-icons/one_password.svg") as &[u8], + ), + ( + "secret/openbao.svg", + include_bytes!("source-control-icons/openbao.svg") as &[u8], + ), + ( + "secret/aws_secrets_manager.svg", + include_bytes!("source-control-icons/aws_secrets_manager.svg") as &[u8], + ), +]; + +/// The mark of a secret provider kind, or `None` for a kind the web draws as a key. +pub fn secret_provider_icon(kind: &str) -> Option<&'static str> { + SECRET_ICONS + .iter() + .find(|(path, _)| { + path.strip_prefix("secret/") + .and_then(|p| p.strip_suffix(".svg")) + == Some(kind) + }) + .map(|(path, _)| *path) +} + +/// The mark of a version control system or hosting provider kind, or `None` for a kind without one. +pub fn source_control_icon(kind: &str, dark: bool) -> Option<&'static str> { + let scheme = if dark { "dark" } else { "light" }; + [ + format!("source-control/{kind}-{scheme}.svg"), + format!("source-control/{kind}.svg"), + ] + .iter() + .find_map(|wanted| SOURCE_CONTROL_ICONS.iter().find(|(path, _)| path == wanted)) + .map(|(path, _)| *path) +} + pub struct AppAssets; impl AssetSource for AppAssets { @@ -318,6 +399,15 @@ impl AssetSource for AppAssets { if let Some((_, bytes)) = PROVIDER_ICONS.iter().find(|(known, _)| *known == path) { return Ok(Some(Cow::Borrowed(bytes))); } + if let Some((_, bytes)) = SOURCE_CONTROL_ICONS + .iter() + .find(|(known, _)| *known == path) + { + return Ok(Some(Cow::Borrowed(bytes))); + } + if let Some((_, bytes)) = SECRET_ICONS.iter().find(|(known, _)| *known == path) { + return Ok(Some(Cow::Borrowed(bytes))); + } if let Some((_, bytes)) = CHIP_ICONS.iter().find(|(known, _)| *known == path) { return Ok(Some(Cow::Borrowed(bytes))); } diff --git a/apps/desktop-gpui/src/clone_flow.rs b/apps/desktop-gpui/src/clone_flow.rs new file mode 100644 index 000000000000..1266753ff149 --- /dev/null +++ b/apps/desktop-gpui/src/clone_flow.rs @@ -0,0 +1,1004 @@ +//! The logic of the web's "Add to another machine" dialog, without the dialog: clone URLs +//! (`packages/shared/src/cloneUrl.ts`), the folder a clone lands in (`getCloneDirectoryPath` and +//! friends of `packages/client-runtime/src/operations/projects.ts`), the ignored files to bring +//! along (`apps/web/src/lib/addProjectToMachine.ts`) and the request payloads of the RPCs it +//! calls. + +use serde_json::{Value, json}; +use std::collections::HashSet; + +/// Matches the server's read cap: a larger file would arrive truncated. +pub const COPYABLE_FILE_MAX_BYTES: u64 = 1024 * 1024; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Protocol { + Ssh, + Https, +} + +struct Parsed<'a> { + protocol: Protocol, + host: &'a str, + path: &'a str, + custom_port: bool, +} + +/// `(?:[^/\s]*@)?(\[[^\]@/\s]+\]|[^@:/\s]+)(:\d+)?/(.+)` after the scheme. +fn parse_authority<'a>(rest: &'a str, protocol: Protocol) -> Option> { + let slash = rest.find('/')?; + let (authority, path) = (&rest[..slash], &rest[slash + 1..]); + if authority.chars().any(char::is_whitespace) || path.is_empty() { + return None; + } + let host_and_port = authority + .rsplit_once('@') + .map_or(authority, |(_, tail)| tail); + let (host, custom_port) = match host_and_port.strip_prefix('[') { + Some(inner) => { + let close = inner.find(']')?; + let after = &inner[close + 1..]; + let host = &host_and_port[..close + 2]; + if close == 0 || inner[..close].contains('@') { + return None; + } + match after { + "" => (host, false), + port if is_port(port) => (host, true), + _ => return None, + } + } + None => match host_and_port.split_once(':') { + Some((host, port)) if is_port(&format!(":{port}")) => (host, true), + Some(_) => return None, + None => (host_and_port, false), + }, + }; + (!host.is_empty() && !host.contains('@')).then_some(Parsed { + protocol, + host, + path, + custom_port, + }) +} + +fn is_port(text: &str) -> bool { + text.strip_prefix(':') + .is_some_and(|digits| !digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())) +} + +/// `SCP_LIKE_URL`: `[user@]host:path`, where the path does not start with a slash. +fn parse_scp(text: &str) -> Option> { + let attempt = |rest: &'_ str| -> Option<(usize, usize)> { + let (host_len, after) = if let Some(inner) = rest.strip_prefix('[') { + let close = inner.find(']')?; + let body = &inner[..close]; + if body.is_empty() + || body + .chars() + .any(|c| c == '@' || c == '/' || c.is_whitespace()) + { + return None; + } + (close + 2, &rest[close + 2..]) + } else { + let end = rest.find(':')?; + let host = &rest[..end]; + if host.is_empty() + || host + .chars() + .any(|c| c == '/' || c == '[' || c == ']' || c.is_whitespace()) + { + return None; + } + (end, &rest[end..]) + }; + let path = after.strip_prefix(':')?; + if path.is_empty() || path.starts_with('/') { + return None; + } + Some((host_len, rest.len() - path.len())) + }; + let from_rest = |offset: usize| { + let rest = &text[offset..]; + attempt(rest).map(|(host_len, path_start)| Parsed { + protocol: Protocol::Ssh, + host: &text[offset..offset + host_len], + path: &text[offset + path_start..], + custom_port: false, + }) + }; + let with_user = text.find('@').and_then(|at| { + let user = &text[..at]; + let valid = !user.is_empty() && !user.chars().any(|c| c == '/' || c.is_whitespace()); + if valid { from_rest(at + 1) } else { None } + }); + with_user.or_else(|| from_rest(0)) +} + +fn parse(url: &str) -> Option> { + let text = url.trim(); + if let Some(rest) = text.strip_prefix("ssh://") + && let Some(parsed) = parse_authority(rest, Protocol::Ssh) + { + return Some(parsed); + } + for scheme in ["https://", "http://"] { + if let Some(rest) = text.strip_prefix(scheme) + && let Some(parsed) = parse_authority(rest, Protocol::Https) + { + return Some(parsed); + } + } + parse_scp(text) +} + +pub fn url_protocol(url: &str) -> Option { + parse(url).map(|parsed| parsed.protocol) +} + +/// The path of an Azure URL as `[organization, project, repository]`, after the fixed marker. +fn azure_parts(path: &str, marker_index: usize, marker: &str) -> Option<[String; 3]> { + let parts: Vec<&str> = path.split('/').collect(); + if parts.len() != 4 || parts[marker_index] != marker { + return None; + } + let mut names = parts + .iter() + .enumerate() + .filter(|(index, _)| *index != marker_index) + .map(|(_, part)| *part); + let mut next = || { + names + .next() + .filter(|part| !part.is_empty() && !part.contains(['?', '#'])) + .map(str::to_owned) + }; + Some([next()?, next()?, next()?]) +} + +/// Rewrites a clone URL to the other protocol, because the target machine may only have SSH keys +/// or only an HTTPS credential helper. `None` when it cannot be rewritten safely. +pub fn url_for_protocol(url: &str, protocol: Protocol) -> Option { + let parsed = parse(url)?; + if parsed.protocol == protocol { + return Some(url.trim().to_owned()); + } + if parsed.custom_port { + return None; + } + let host = parsed.host.to_lowercase(); + if parsed.protocol == Protocol::Ssh && host == "ssh.dev.azure.com" { + let [org, project, repository] = azure_parts(parsed.path, 0, "v3")?; + return Some(format!( + "https://dev.azure.com/{org}/{project}/_git/{repository}" + )); + } + if parsed.protocol == Protocol::Https && host == "dev.azure.com" { + let [org, project, repository] = azure_parts(parsed.path, 2, "_git")?; + return Some(format!( + "git@ssh.dev.azure.com:v3/{org}/{project}/{repository}" + )); + } + Some(match protocol { + Protocol::Ssh => format!("git@{}:{}", parsed.host, parsed.path), + Protocol::Https => format!("https://{}/{}", parsed.host, parsed.path), + }) +} + +fn is_windows_drive_path(value: &str) -> bool { + let mut chars = value.chars(); + matches!( + (chars.next(), chars.next(), chars.next()), + (Some(letter), Some(':'), None | Some('/' | '\\')) if letter.is_ascii_alphabetic() + ) +} + +fn is_unc_path(value: &str) -> bool { + value.starts_with("\\\\") +} + +fn is_windows_absolute_path(value: &str) -> bool { + is_unc_path(value) || is_windows_drive_path(value) +} + +fn is_host_path(value: &str) -> bool { + value.starts_with('/') || is_windows_absolute_path(value) || value.starts_with("~/") +} + +fn unsafe_segment(segment: &str) -> bool { + matches!(segment, "." | "..") + || segment + .chars() + .any(|c| matches!(c, '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|') || c.is_control()) + || segment.ends_with([' ', '.']) +} + +fn windows_device_name(segment: &str) -> bool { + let lower = segment.to_lowercase(); + let digits = [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '\u{b9}', '\u{b2}', '\u{b3}', + ]; + let mut names: Vec = ["con", "prn", "aux", "nul", "conin$", "conout$"] + .iter() + .map(|name| (*name).to_owned()) + .collect(); + for prefix in ["com", "lpt"] { + names.extend(digits.iter().map(|digit| format!("{prefix}{digit}"))); + } + names.iter().any(|name| { + lower.strip_prefix(name.as_str()).is_some_and(|rest| { + let rest = rest.trim_start_matches(' '); + rest.is_empty() || rest.starts_with('.') + }) + }) +} + +/// The `//` folders ghq clones a URL into, or `None` for local paths and +/// anything with a folder that could leave the root. +pub fn repository_path(url: &str) -> Option { + if is_windows_drive_path(url.trim()) { + return None; + } + let parsed = parse(url)?; + let path = parsed.path.split(['?', '#']).next().unwrap_or_default(); + let mut segments: Vec = std::iter::once(parsed.host.to_owned()) + .chain(path.split('/').filter(|s| !s.is_empty()).map(str::to_owned)) + .collect(); + if let Some(last) = segments.last_mut() + && let Some(stripped) = last.strip_suffix(".git") + { + *last = stripped.to_owned(); + } + let safe = segments.len() >= 2 + && segments + .iter() + .all(|s| !s.is_empty() && !unsafe_segment(s) && !windows_device_name(s)); + safe.then(|| segments.join("/")) +} + +/// `getCloneDirectoryName`. +pub fn directory_name(repository_or_url: &str) -> String { + let without_query = repository_or_url + .split(['?', '#']) + .next() + .unwrap_or_default() + .trim(); + let scheme = without_query.find("://"); + let has_host = scheme.is_some() || has_scp_prefix(without_query); + let path = scheme.map_or(without_query, |at| &without_query[at + 3..]); + let segments: Vec<&str> = path + .split(['/', '\\', ':']) + .filter(|segment| !segment.trim().is_empty()) + .collect(); + if has_host && segments.len() < 2 { + return String::new(); + } + let last = segments.last().map_or("", |segment| segment.trim()); + if has_host + && segments.len() == 2 + && !last.is_empty() + && last.chars().all(|c| c.is_ascii_digit()) + { + return String::new(); + } + last.strip_suffix(".git").unwrap_or(last).to_owned() +} + +/// `^[^/\\:]+@[^/\\:]+:` +fn has_scp_prefix(text: &str) -> bool { + let run_end = text.find(['/', '\\', ':']).unwrap_or(text.len()); + if !text[run_end..].starts_with(':') { + return false; + } + let run = &text[..run_end]; + run.char_indices() + .any(|(index, c)| c == '@' && index >= 1 && index + 1 < run.len()) +} + +/// The folder a clone gets below its base: ``, or `//` like ghq. +pub fn directory_path(remote_url: &str, ghq_layout: bool) -> String { + ghq_layout + .then(|| repository_path(remote_url)) + .flatten() + .unwrap_or_else(|| directory_name(remote_url)) +} + +fn preferred_separator(value: &str) -> char { + if is_windows_absolute_path(value) { + '\\' + } else if value.starts_with('/') { + '/' + } else if value.contains('\\') { + '\\' + } else { + '/' + } +} + +fn has_trailing_separator(value: &str) -> bool { + if !is_windows_absolute_path(value) && value.starts_with('/') { + value.ends_with('/') + } else { + value.ends_with(['/', '\\']) + } +} + +fn ensure_directory_path(path: &str) -> String { + let trimmed = path.trim(); + if trimmed.is_empty() || has_trailing_separator(trimmed) { + return trimmed.to_owned(); + } + format!("{trimmed}{}", preferred_separator(trimmed)) +} + +/// `getAddProjectInitialQuery`: the base folder clones land in, home when none is set. +pub fn initial_query(base_directory: Option<&str>) -> String { + let trimmed = base_directory.map_or("", str::trim); + if trimmed.is_empty() { + "~/".to_owned() + } else { + ensure_directory_path(trimmed) + } +} + +/// `getCloneDestinationPath`. +pub fn destination_path(directory: &str, name: &str) -> String { + let name = name.trim(); + if name.is_empty() { + return directory.to_owned(); + } + let base = ensure_directory_path(directory); + if base.ends_with('\\') { + format!("{base}{}", name.replace('/', "\\")) + } else { + format!("{base}{name}") + } +} + +/// Env files are the ignored files people almost always want on a new machine. +pub fn is_default_copied(path: &str) -> bool { + let name = path.rsplit('/').next().unwrap_or(path); + name == ".env" + || name + .strip_prefix(".env.") + .is_some_and(|rest| !rest.is_empty()) + || name == ".envrc" + || name == ".dev.vars" +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct IgnoredFile { + pub path: String, + pub byte_length: u64, +} + +impl IgnoredFile { + pub fn copyable(&self) -> bool { + self.byte_length <= COPYABLE_FILE_MAX_BYTES + } +} + +/// What the dialog checks before the user touches the list. +pub fn default_selection(files: &[IgnoredFile]) -> HashSet { + files + .iter() + .filter(|file| file.copyable() && is_default_copied(&file.path)) + .map(|file| file.path.clone()) + .collect() +} + +/// The set the All/None button leaves behind. +pub fn toggled_all(selected: &HashSet, files: &[IgnoredFile]) -> HashSet { + let copyable: HashSet = files + .iter() + .filter(|file| file.copyable()) + .map(|file| file.path.clone()) + .collect(); + if !copyable.is_empty() && selected.len() == copyable.len() { + HashSet::new() + } else { + copyable + } +} + +/// Whether the button reads "None" (everything copyable is selected) rather than "All". +pub fn all_selected(selected: &HashSet, files: &[IgnoredFile]) -> bool { + let copyable = files.iter().filter(|file| file.copyable()).count(); + copyable > 0 && selected.len() == copyable +} + +fn format_scaled(value: f64) -> String { + let digits = if value >= 100. { + 0 + } else if value >= 10. { + 1 + } else { + 2 + }; + format!("{value:.digits$}") +} + +/// `formatBytes`. +pub fn format_bytes(value: u64) -> String { + let value = value as f64; + if value.round() < 1024. { + return format!("{} B", value.round()); + } + let units = ["KB", "MB", "GB", "TB"]; + let mut next = value / 1024.; + let mut index = 0; + // Pick the unit after rounding, so 1023.6 KB reads "1.00 MB", not "1024 KB". + while format_scaled(next).parse::().unwrap_or(0.) >= 1024. && index < units.len() - 1 { + next /= 1024.; + index += 1; + } + format!("{} {}", format_scaled(next), units[index]) +} + +/// The size column of a file row. +pub fn size_label(file: &IgnoredFile) -> String { + if file.copyable() { + format_bytes(file.byte_length) + } else { + "Over 1 MB".to_owned() + } +} + +/// A project can be rooted below its repository's top folder. The clone lands at the top, so +/// copied files keep the same path below it. +pub fn project_subdirectory(repository_root: Option<&str>, workspace_root: &str) -> String { + let Some(root) = repository_root.filter(|root| !root.is_empty()) else { + return String::new(); + }; + let prefix = root.trim_end_matches(['/', '\\']); + match workspace_root.strip_prefix(prefix) { + Some(rest) if rest.starts_with(['/', '\\']) => rest[1..].replace('\\', "/"), + _ => String::new(), + } +} + +/// Where a copied file lands relative to the clone. +pub fn copy_target(subdirectory: &str, relative_path: &str) -> String { + if subdirectory.is_empty() { + relative_path.to_owned() + } else { + format!("{subdirectory}/{relative_path}") + } +} + +/// The text that opens a quoted path at its end: `'/a b'` or `"C:\a b"`, then only whitespace. +fn quoted_path_at_end(text: &str) -> Option<(usize, String)> { + fn single(text: &str, from: usize) -> Option { + // `(?:[^']|'\\'')+` then the closing quote, then only whitespace. + fn walk(text: &str, at: usize, start: usize) -> Option { + let rest = &text[at..]; + if let Some(tail) = rest.strip_prefix("'\\''") + && let Some(found) = walk(text, text.len() - tail.len(), start) + { + return Some(found); + } + if let Some(tail) = rest.strip_prefix('\'') { + return (at > start && tail.trim().is_empty()).then(|| text[start..at].to_owned()); + } + let next = rest.chars().next()?; + walk(text, at + next.len_utf8(), start) + } + walk(text, from, from) + } + for (index, c) in text.char_indices() { + if c == '\'' { + if let Some(content) = single(text, index + 1) { + return Some((index, content.replace("'\\''", "'"))); + } + } else if c == '"' { + let body = &text[index + 1..]; + if let Some(close) = body.find('"') + && close > 0 + && body[close + 1..].trim().is_empty() + { + return Some((index, body[..close].to_owned())); + } + } + } + None +} + +fn shell_unescape(path: &str) -> String { + const ESCAPED: &str = "'\"\\()[]{}<>|&;$`!*?#~^="; + let mut out = String::with_capacity(path.len()); + let mut chars = path.chars().peekable(); + while let Some(c) = chars.next() { + match chars.peek() { + Some(&next) if c == '\\' && (next.is_whitespace() || ESCAPED.contains(next)) => { + out.push(next); + chars.next(); + } + _ => out.push(c), + } + } + out +} + +/// `resolvePastedPath`: what a folder box holds after a change. A full path pasted into it +/// replaces the box, even after the text already there, because `~/'/a b'` would create that +/// nested folder. +pub fn resolve_pasted_path(previous: &str, next: &str) -> String { + let (previous_len, next_len) = (previous.chars().count(), next.chars().count()); + let typing = if next.starts_with(previous) { + next_len == previous_len + 1 + } else { + previous.starts_with(next) + }; + if typing { + return next.to_owned(); + } + if let Some((index, quoted)) = quoted_path_at_end(next) + && previous.starts_with(next[..index].trim_end()) + && is_host_path("ed) + { + return quoted; + } + let after_folder = previous.ends_with(['/', '\\']) && next.starts_with(previous); + let path = if after_folder { + &next[previous.len()..] + } else { + next + }; + if !is_host_path(path) { + return next.to_owned(); + } + // `\` separates folders in Windows paths, so it only escapes in `/` and `~/` paths. + if is_windows_absolute_path(path) { + path.to_owned() + } else { + shell_unescape(path) + } +} + +/// `projects.listIgnoredFiles`, answered by the source environment. +pub fn list_payload(cwd: &str) -> Value { + json!({ "cwd": cwd }) +} + +/// `projects.readFile`, answered by the source environment. +pub fn read_payload(cwd: &str, relative_path: &str) -> Value { + json!({ "cwd": cwd, "relativePath": relative_path }) +} + +/// `projects.writeFile`, answered by the target environment. +pub fn write_payload(cwd: &str, relative_path: &str, contents: &str) -> Value { + json!({ "cwd": cwd, "relativePath": relative_path, "contents": contents }) +} + +/// `projectClone.start`, answered by the target environment. +pub fn start_payload( + project_id: &str, + title: &str, + created_at: &str, + remote_url: &str, + destination_path: &str, +) -> Value { + json!({ + "projectId": project_id, + "title": title, + "createdAt": created_at, + "remoteUrl": remote_url, + "destinationPath": destination_path, + }) +} + +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct IgnoredFiles { + pub files: Vec, + pub truncated: bool, +} + +pub fn decode_ignored_files(value: &Value) -> Option { + let files = value + .get("files")? + .as_array()? + .iter() + .map(|file| { + Some(IgnoredFile { + path: file.get("path")?.as_str()?.to_owned(), + byte_length: file.get("byteLength")?.as_u64()?, + }) + }) + .collect::>>()?; + Some(IgnoredFiles { + files, + truncated: value + .get("truncated") + .and_then(Value::as_bool) + .unwrap_or(false), + }) +} + +/// What the dialog reads from `server.getConfig`. +#[derive(Clone, Debug, PartialEq, Eq, Default)] +pub struct ServerSettings { + /// `environment.capabilities.ignoredFileListing`. + pub ignored_file_listing: bool, + /// `settings.addProjectBaseDirectory`. + pub base_directory: Option, + /// `settings.ghqCloneLayout`. + pub ghq_layout: bool, +} + +pub fn decode_config(value: &Value) -> ServerSettings { + let flag = |value: Option<&Value>| value.and_then(Value::as_bool) == Some(true); + let capabilities = value.pointer("/environment/capabilities"); + ServerSettings { + ignored_file_listing: flag(capabilities.and_then(|c| c.get("ignoredFileListing"))), + base_directory: value + .pointer("/settings/addProjectBaseDirectory") + .and_then(Value::as_str) + .map(str::to_owned), + ghq_layout: flag(value.pointer("/settings/ghqCloneLayout")), + } +} + +/// What reading one file yields: its text, or why it cannot be copied. +pub fn decode_read(value: &Value) -> Result { + if value.get("truncated").and_then(Value::as_bool) == Some(true) { + return Err("Too large to copy.".to_owned()); + } + value + .get("contents") + .and_then(Value::as_str) + .map(str::to_owned) + .ok_or_else(|| "The server sent no contents.".to_owned()) +} + +/// The clone's folder on the target, from `projectClone.start`. +pub fn decode_started(value: &Value) -> Option { + value.get("cwd")?.as_str().map(str::to_owned) +} + +/// Where a clone stands, as `waitForClone` reads the clone list: it is over once the clone is +/// done, or gone after it was seen. A failed or cancelled clone keeps waiting, since the web +/// composer offers a retry. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum CloneWait { + Pending, + Done, + Gone, +} + +pub fn clone_wait(list: &Value, project_id: &str, seen: &mut bool) -> CloneWait { + let clone = list.as_array().and_then(|clones| { + clones + .iter() + .find(|c| c.get("projectId").and_then(Value::as_str) == Some(project_id)) + }); + if clone.is_some() { + *seen = true; + } + match clone { + Some(clone) if clone.get("phase").and_then(Value::as_str) == Some("done") => { + CloneWait::Done + } + None if *seen => CloneWait::Gone, + _ => CloneWait::Pending, + } +} + +/// The notice for files that did not make it across. +pub fn failure_title(count: usize, target: &str) -> String { + format!( + "Could not copy {count} {} to {target}", + if count == 1 { "file" } else { "files" } + ) +} + +/// The clone button's label. +pub fn submit_label(submitting: bool, target: Option<&str>) -> String { + if submitting { + "Starting\u{2026}".to_owned() + } else { + format!("Clone to {}", target.unwrap_or("machine")) + } +} + +/// The count under the arrow: "no files", "+ 1 file", "+ 3 files". +pub fn file_count_label(count: usize) -> String { + match count { + 0 => "no files".to_owned(), + 1 => "+ 1 file".to_owned(), + count => format!("+ {count} files"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn file(path: &str, byte_length: u64) -> IgnoredFile { + IgnoredFile { + path: path.to_owned(), + byte_length, + } + } + + #[test] + fn config_yields_the_capability_and_clone_settings() { + let config = json!({ + "environment": { "capabilities": { "ignoredFileListing": true } }, + "settings": { "addProjectBaseDirectory": "~/code", "ghqCloneLayout": true }, + }); + assert_eq!( + decode_config(&config), + ServerSettings { + ignored_file_listing: true, + base_directory: Some("~/code".to_owned()), + ghq_layout: true, + } + ); + assert_eq!(decode_config(&json!({})), ServerSettings::default()); + } + + #[test] + fn protocols_are_read_from_each_url_shape() { + assert_eq!( + url_protocol("git@host.test:acme/app.git"), + Some(Protocol::Ssh) + ); + assert_eq!( + url_protocol("ssh://git@host.test/acme/app.git"), + Some(Protocol::Ssh) + ); + assert_eq!( + url_protocol("https://host.test/acme/app.git"), + Some(Protocol::Https) + ); + assert_eq!( + url_protocol("http://user:pw@host.test/acme/app"), + Some(Protocol::Https) + ); + assert_eq!(url_protocol("host.test:acme/app"), Some(Protocol::Ssh)); + assert_eq!(url_protocol("file:///srv/app"), None); + assert_eq!(url_protocol("not a url"), None); + } + + #[test] + fn urls_rewrite_between_protocols() { + let https = "https://host.test/acme/app.git"; + let ssh = "git@host.test:acme/app.git"; + assert_eq!(url_for_protocol(https, Protocol::Ssh).as_deref(), Some(ssh)); + assert_eq!( + url_for_protocol(ssh, Protocol::Https).as_deref(), + Some(https) + ); + assert_eq!( + url_for_protocol(&format!(" {https} "), Protocol::Https).as_deref(), + Some(https) + ); + assert_eq!( + url_for_protocol("ssh://git@host.test/acme/app", Protocol::Https).as_deref(), + Some("https://host.test/acme/app") + ); + } + + #[test] + fn a_custom_port_cannot_change_protocol() { + assert_eq!( + url_for_protocol("ssh://git@host.test:2222/acme/app", Protocol::Https), + None + ); + assert_eq!( + url_for_protocol("https://host.test:8443/acme/app", Protocol::Ssh), + None + ); + assert_eq!( + url_for_protocol("ssh://git@host.test:2222/acme/app", Protocol::Ssh).as_deref(), + Some("ssh://git@host.test:2222/acme/app") + ); + } + + #[test] + fn azure_urls_move_their_repository_marker() { + assert_eq!( + url_for_protocol("git@ssh.dev.azure.com:v3/org/proj/repo", Protocol::Https).as_deref(), + Some("https://dev.azure.com/org/proj/_git/repo") + ); + assert_eq!( + url_for_protocol("https://dev.azure.com/org/proj/_git/repo", Protocol::Ssh).as_deref(), + Some("git@ssh.dev.azure.com:v3/org/proj/repo") + ); + assert_eq!( + url_for_protocol("https://dev.azure.com/org/proj", Protocol::Ssh), + None + ); + } + + #[test] + fn ghq_layout_uses_host_owner_and_repository() { + assert_eq!( + repository_path("git@host.test:acme/app.git").as_deref(), + Some("host.test/acme/app") + ); + assert_eq!( + repository_path("https://host.test/acme/app?x=1").as_deref(), + Some("host.test/acme/app") + ); + assert_eq!(repository_path("C:\\work\\app"), None); + assert_eq!(repository_path("https://host.test/acme/../app"), None); + assert_eq!(repository_path("https://host.test/acme/nul"), None); + assert_eq!(repository_path("https://host.test/acme/app."), None); + } + + #[test] + fn the_clone_folder_is_the_repository_name() { + assert_eq!(directory_name("https://host.test/acme/app.git"), "app"); + assert_eq!(directory_name("git@host.test:acme/app.git"), "app"); + assert_eq!(directory_name("acme/app"), "app"); + assert_eq!(directory_name("https://host.test/app"), "app"); + assert_eq!(directory_name("https://host.test"), ""); + assert_eq!(directory_name("git@host.test:22"), ""); + assert_eq!(directory_name(""), ""); + } + + #[test] + fn the_clone_folder_follows_the_layout() { + let url = "https://host.test/acme/app.git"; + assert_eq!(directory_path(url, false), "app"); + assert_eq!(directory_path(url, true), "host.test/acme/app"); + assert_eq!(directory_path("acme/app", true), "app"); + } + + #[test] + fn the_destination_joins_base_and_folder() { + assert_eq!(initial_query(None), "~/"); + assert_eq!(initial_query(Some(" ")), "~/"); + assert_eq!(initial_query(Some("/srv/code")), "/srv/code/"); + assert_eq!(initial_query(Some("C:\\code")), "C:\\code\\"); + assert_eq!(destination_path("~/", "app"), "~/app"); + assert_eq!( + destination_path("/srv/code", "host/acme/app"), + "/srv/code/host/acme/app" + ); + assert_eq!( + destination_path("C:\\code", "host/app"), + "C:\\code\\host\\app" + ); + assert_eq!(destination_path("/srv/code", " "), "/srv/code"); + } + + #[test] + fn env_files_are_copied_by_default() { + for path in [".env", "apps/web/.env.local", ".envrc", "pkg/.dev.vars"] { + assert!(is_default_copied(path), "{path}"); + } + for path in [ + ".env.", + "env", + ".environment", + "node/.envrc.bak", + "a/.dev.vars.x", + ] { + assert!(!is_default_copied(path), "{path}"); + } + } + + #[test] + fn selection_defaults_skip_files_over_the_cap() { + let files = [ + file(".env", 10), + file(".env.big", COPYABLE_FILE_MAX_BYTES + 1), + file("notes.txt", 5), + ]; + let selected = default_selection(&files); + assert_eq!(selected, HashSet::from([".env".to_owned()])); + let all = toggled_all(&selected, &files); + assert_eq!(all.len(), 2); + assert!(all_selected(&all, &files)); + assert!(toggled_all(&all, &files).is_empty()); + assert!(!all_selected(&selected, &files)); + } + + #[test] + fn sizes_read_like_the_web() { + assert_eq!(format_bytes(599), "599 B"); + assert_eq!(format_bytes(8192), "8.00 KB"); + assert_eq!(format_bytes(1023 * 1024 + 700), "1.00 MB"); + assert_eq!(format_bytes(150 * 1024), "150 KB"); + assert_eq!( + size_label(&file("a", COPYABLE_FILE_MAX_BYTES + 1)), + "Over 1 MB" + ); + assert_eq!(size_label(&file("a", 599)), "599 B"); + } + + #[test] + fn a_project_below_the_repository_root_keeps_its_subdirectory() { + assert_eq!( + project_subdirectory(Some("/repo/"), "/repo/apps/web"), + "apps/web" + ); + assert_eq!(project_subdirectory(Some("/repo"), "/repo"), ""); + assert_eq!(project_subdirectory(Some("/repo"), "/repository/x"), ""); + assert_eq!(project_subdirectory(None, "/repo/x"), ""); + assert_eq!( + project_subdirectory(Some("C:\\repo"), "C:\\repo\\apps\\web"), + "apps/web" + ); + assert_eq!(copy_target("apps/web", ".env"), "apps/web/.env"); + assert_eq!(copy_target("", ".env"), ".env"); + } + + #[test] + fn typing_keeps_the_text_and_a_paste_replaces_it() { + assert_eq!(resolve_pasted_path("~/co", "~/cod"), "~/cod"); + assert_eq!(resolve_pasted_path("~/cod", "~/co"), "~/co"); + assert_eq!( + resolve_pasted_path("~/code/", "~/code/'/srv/a b'"), + "/srv/a b" + ); + assert_eq!(resolve_pasted_path("~/code", "/srv/a\\ b"), "/srv/a b"); + assert_eq!(resolve_pasted_path("~/code/", "~/code//srv/x"), "/srv/x"); + assert_eq!(resolve_pasted_path("~/code", "plain text"), "plain text"); + assert_eq!(resolve_pasted_path("x", "C:\\a\\ b"), "C:\\a\\ b"); + } + + #[test] + fn requests_carry_the_web_payloads() { + assert_eq!( + read_payload("/r", "a/.env"), + json!({"cwd": "/r", "relativePath": "a/.env"}) + ); + assert_eq!( + write_payload("/t", ".env", "A=1"), + json!({"cwd": "/t", "relativePath": ".env", "contents": "A=1"}) + ); + assert_eq!( + start_payload("p", "App", "2026-01-01T00:00:00.000Z", "u", "/t"), + json!({"projectId": "p", "title": "App", "createdAt": "2026-01-01T00:00:00.000Z", "remoteUrl": "u", "destinationPath": "/t"}) + ); + } + + #[test] + fn answers_decode_or_say_why_not() { + let listed = decode_ignored_files(&json!({ + "files": [{"path": ".env", "byteLength": 4}], + "truncated": true, + })) + .unwrap(); + assert_eq!(listed.files, vec![file(".env", 4)]); + assert!(listed.truncated); + assert_eq!(decode_ignored_files(&json!({"files": [{"path": 1}]})), None); + assert_eq!( + decode_read(&json!({"contents": "A=1", "truncated": false})), + Ok("A=1".to_owned()) + ); + assert_eq!( + decode_read(&json!({"contents": "A", "truncated": true})), + Err("Too large to copy.".to_owned()) + ); + assert_eq!(decode_started(&json!({"cwd": "/t"})).as_deref(), Some("/t")); + } + + #[test] + fn the_wait_ends_when_the_clone_is_done_or_gone() { + let mut seen = false; + assert_eq!(clone_wait(&json!([]), "p", &mut seen), CloneWait::Pending); + let running = json!([{"projectId": "p", "phase": "running"}]); + assert_eq!(clone_wait(&running, "p", &mut seen), CloneWait::Pending); + let failed = json!([{"projectId": "p", "phase": "failed"}]); + assert_eq!(clone_wait(&failed, "p", &mut seen), CloneWait::Pending); + assert_eq!(clone_wait(&json!([]), "p", &mut seen), CloneWait::Gone); + let done = json!([{"projectId": "p", "phase": "done"}]); + assert_eq!(clone_wait(&done, "p", &mut false), CloneWait::Done); + } + + #[test] + fn labels_follow_the_counts() { + assert_eq!(file_count_label(0), "no files"); + assert_eq!(file_count_label(1), "+ 1 file"); + assert_eq!(file_count_label(4), "+ 4 files"); + assert_eq!(failure_title(1, "Box"), "Could not copy 1 file to Box"); + assert_eq!(failure_title(2, "Box"), "Could not copy 2 files to Box"); + assert_eq!(submit_label(true, Some("Box")), "Starting\u{2026}"); + assert_eq!(submit_label(false, Some("Box")), "Clone to Box"); + assert_eq!(submit_label(false, None), "Clone to machine"); + } +} diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index 3e17f1321410..cff9dec96bfc 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -9,10 +9,15 @@ use crate::rpc::{ AttachmentsCreateUploadUrl, AttachmentsDelete, DeviceConfigure, DeviceEasAccount, DeviceList, DeviceTestHost, Event, GitRunStackedAction, OrchestrationDispatchCommand, OrchestrationLaunchThread, PING_FRAME, ProjectsMutate, ProviderConsumeResetCredit, - ReviewGetDiffPreview, ServerGetConfig, ServerGetUsageSummary, ServerRefreshProviders, - ServerRefreshUsageRates, ServerUpdateSettings, Session, ShellOpenInEditor, SubscribeShell, - SubscribeThread, SubscribeVcsStatus, TerminalAttach, TerminalClose, TerminalResize, - TerminalWrite, VcsCreateRef, VcsListRefs, VcsPull, VcsRefreshStatus, VcsSwitchRef, + ReviewGetDiffPreview, ServerCheckSecretSource, ServerDiscoverSourceControl, ServerGetConfig, + ServerGetUsageSummary, ServerRefreshProviders, ServerRefreshUsageRates, ServerUpdateSettings, + Session, ShellOpenInEditor, SubscribeShell, SubscribeThread, SubscribeVcsStatus, + TerminalAttach, TerminalClose, TerminalResize, TerminalWrite, VcsCreateRef, VcsListRefs, + VcsPull, VcsRefreshStatus, VcsSwitchRef, +}; +use crate::rpc::{ + ProjectCloneStart, ProjectsListIgnoredFiles, ProjectsReadFile, ProjectsWriteFile, + SubscribeProjectClones, }; use crate::shell::ShellState; use crate::status::{CallEvent, Status, StatusSender, publish}; @@ -109,6 +114,10 @@ pub enum Action { call: Call, payload: serde_json::Value, }, + /// Stops a streaming `Call`; nothing more arrives under `token`. + EndCall { + token: u64, + }, } /// The RPCs an `Action::Call` can name, all with untyped payloads. @@ -134,6 +143,15 @@ pub enum Call { DeviceConfigure, DeviceTestHost, DeviceEasAccount, + DiscoverSourceControl, + CheckSecretSource, + GetConfig, + ListIgnoredFiles, + ReadFile, + WriteFile, + StartProjectClone, + /// A stream of the clone list that never ends by itself. + WatchProjectClones, } pub type ActionSender = async_channel::Sender; @@ -156,6 +174,7 @@ impl Action { Self::TerminalClose { .. } => "close the terminal", Self::Upload { .. } => "upload the attachment", Self::Call { call, .. } => call.describe(), + Self::EndCall { .. } => "end the request", } } } @@ -182,6 +201,14 @@ impl Call { Self::DeviceConfigure => "update the device settings", Self::DeviceTestHost => "test the connection", Self::DeviceEasAccount => "check the Expo account", + Self::DiscoverSourceControl => "scan the server environment", + Self::CheckSecretSource => "check the secret", + Self::GetConfig => "load the server settings", + Self::ListIgnoredFiles => "list the ignored files", + Self::ReadFile => "read the file", + Self::WriteFile => "write the file", + Self::StartProjectClone => "start the clone", + Self::WatchProjectClones => "watch the clones", } } @@ -210,6 +237,14 @@ impl Call { Self::DeviceConfigure => session.request::(payload), Self::DeviceTestHost => session.request::(payload), Self::DeviceEasAccount => session.request::(payload), + Self::DiscoverSourceControl => session.request::(payload), + Self::CheckSecretSource => session.request::(payload), + Self::GetConfig => session.request::(payload), + Self::ListIgnoredFiles => session.request::(payload), + Self::ReadFile => session.request::(payload), + Self::WriteFile => session.request::(payload), + Self::StartProjectClone => session.request::(payload), + Self::WatchProjectClones => session.request::(payload), } } } @@ -542,6 +577,17 @@ async fn serve( } continue; } + if let Action::EndCall { token } = &action { + let id = calls.iter().find_map(|(id, owner)| (owner == token).then(|| id.clone())); + if let Some(id) = id { + calls.remove(&id); + let frame = session.interrupt(&id); + if let Err(error) = sink.send(Message::text(frame)).await { + return error.to_string(); + } + } + continue; + } let request = match &action { Action::Upload { token, relative_url, path, mime } => { uploader.start(*token, relative_url, path.clone(), mime.clone(), status); @@ -645,7 +691,7 @@ async fn serve( Action::LoadBranch { root } => session .request::(&refs_query(root)) .map(|(id, frame)| (id, frame, Some(root.clone()))), - Action::Call { .. } => continue, + Action::Call { .. } | Action::EndCall { .. } => continue, }; match request { Ok((id, frame, root)) => { diff --git a/apps/desktop-gpui/src/environments.rs b/apps/desktop-gpui/src/environments.rs index 17a19d7557cc..3818162224b5 100644 --- a/apps/desktop-gpui/src/environments.rs +++ b/apps/desktop-gpui/src/environments.rs @@ -1,13 +1,25 @@ use crate::auth::{self, Credential}; -use crate::connection::{self, Ended}; +use crate::connection::{self, ActionSender, Ended}; use crate::credentials::{CredentialStore, StoredCredential}; use crate::pairing; use crate::projects::EnvironmentProjects; use crate::server; use crate::status::{Status, StatusSender, publish}; -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; use tokio::time::sleep; +fn senders() -> &'static Mutex> { + static SENDERS: OnceLock>> = OnceLock::new(); + SENDERS.get_or_init(Mutex::default) +} + +/// Where actions for a saved environment go: its own connection, whose `Status::Call` answers +/// reach the window through the same status stream as the primary's. +pub fn sender(environment_id: &str) -> Option { + senders().lock().ok()?.get(environment_id).cloned() +} + /// Lists what the Keychain holds for environments other than `primary_id` and keeps a connection /// to each reachable one. Any failure costs only the badges, and is logged once. pub async fn run(primary_id: String, store: Arc, status: StatusSender) { @@ -33,7 +45,10 @@ async fn follow(store: Arc, saved: StoredCredential, status let client = auth::http_client(); let (own_tx, own_rx) = async_channel::unbounded(); // The connection ends its session when the action channel closes, so keep the sender here. - let (_actions_tx, actions_rx) = async_channel::unbounded(); + let (actions_tx, actions_rx) = async_channel::unbounded(); + if let Ok(mut senders) = senders().lock() { + senders.insert(saved.environment_id.clone(), actions_tx.clone()); + } let mut backoff = connection::Backoff::new(); let environment = loop { match server::fetch_environment(&client, &saved.http_base).await { @@ -55,6 +70,10 @@ async fn follow(store: Arc, saved: StoredCredential, status let forward = async { let mut last: Option = None; while let Ok(update) = own_rx.recv().await { + if matches!(update, Status::Call { .. }) { + publish(&status, update); + continue; + } let (projects, connected) = match update { Status::Connected { shell: Some(shell), .. @@ -96,6 +115,10 @@ async fn follow(store: Arc, saved: StoredCredential, status ); // Close the channel so `forward` ends and the badges clear. own_tx.close(); + if let Ok(mut senders) = senders().lock() { + senders.remove(&saved.environment_id); + } + drop(actions_tx); }; tokio::join!(forward, session); } diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 24af13f35dc8..22481e143b31 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -2,6 +2,7 @@ mod assets; mod attachments; mod auth; mod branch_picker; +mod clone_flow; mod config; mod connection; mod credentials; @@ -19,6 +20,7 @@ mod providers; mod ref_picker; mod refs; mod rpc; +mod secret_source; mod send; mod server; mod settings; @@ -26,6 +28,7 @@ mod settings_appearance; mod settings_devices; mod settings_diffs; mod settings_labs; +mod settings_source_control; mod settings_typography; mod shell; mod slow_requests; @@ -115,6 +118,7 @@ fn main() { Theme::change(ThemeMode::Dark, None, cx); // The web's default `sidebar.toggle` and `navigation.*` bindings. cx.bind_keys(ui::usage_bindings()); + cx.bind_keys(ui::add_to_machine_bindings()); cx.bind_keys(ui::settings_bindings()); cx.bind_keys([ KeyBinding::new("cmd-b", ui::ToggleSidebar, None), diff --git a/apps/desktop-gpui/src/projects.rs b/apps/desktop-gpui/src/projects.rs index b241dd5982d2..b9508a3a6aea 100644 --- a/apps/desktop-gpui/src/projects.rs +++ b/apps/desktop-gpui/src/projects.rs @@ -105,6 +105,34 @@ pub struct ProjectEntry { pub logical_key: Option, /// Other environments that have the same logical project. pub remote: Vec, + /// The representative's repository, which the add-to-another-machine dialog clones. + pub repository: Option, +} + +/// What `project.repositoryIdentity` says about where a project came from. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RepositoryInfo { + /// `origin.remoteUrl`, else the locator's: the URL a clone starts from. + pub remote_url: String, + /// `displayName`, which names the repository in the dialog's subtitle. + pub display_name: Option, + /// The repository's top folder, where the project may sit below. + pub root_path: Option, +} + +fn repository_info(project: &Project) -> Option { + let identity = project.repository_identity.as_ref()?; + let remote_url = identity + .origin + .as_ref() + .and_then(|origin| origin.remote_url.clone()) + .unwrap_or_else(|| identity.locator.remote_url.clone()); + // `useAddProjectTargets` gates on the locator, so a project without one offers no clone. + (!identity.locator.remote_url.trim().is_empty()).then(|| RepositoryInfo { + remote_url, + display_name: identity.display_name.clone(), + root_path: identity.root_path.clone(), + }) } impl ProjectEntry { @@ -416,6 +444,7 @@ pub fn build_entries<'a>( scratch: is_scratch(representative), root: representative.workspace_root.clone(), remote: Vec::new(), + repository: repository_info(representative), }; (entry, activity) }) @@ -639,6 +668,7 @@ mod tests { root: String::new(), logical_key: None, remote: Vec::new(), + repository: None, }; let opened = ["a", "b", "c"].map(str::to_owned); let now = [entry("d", false), entry("c", true), entry("a", false)]; @@ -668,6 +698,7 @@ mod tests { root: root.to_owned(), logical_key: None, remote: Vec::new(), + repository: None, } } diff --git a/apps/desktop-gpui/src/providers.rs b/apps/desktop-gpui/src/providers.rs index 1d721bc20769..18fc28a0a4b0 100644 --- a/apps/desktop-gpui/src/providers.rs +++ b/apps/desktop-gpui/src/providers.rs @@ -354,7 +354,7 @@ struct FileAttachments { } /// The provider snapshots and settings of one environment. -#[derive(Clone, Debug, Default, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct Catalog { providers: Vec, settings: Settings, @@ -365,6 +365,8 @@ pub struct Catalog { usage_model_aliases: bool, /// The Devices tab's keys. devices: crate::settings_devices::DeviceConfig, + /// The Source control tab's keys. + source_control: crate::settings_source_control::SourceControlConfig, } /// The two-letter instance chip at the icon's bottom-right. @@ -452,6 +454,13 @@ impl Entry<'_> { impl Catalog { pub fn decode(value: Value) -> Result { let devices = crate::settings_devices::DeviceConfig::from_settings(&value["settings"]); + let source_control = + crate::settings_source_control::SourceControlConfig::from_settings(&value["settings"]) + .with_secret_sources( + &value["settings"], + value["environment"]["capabilities"]["secretReferences"].as_bool() + == Some(true), + ); let Config { providers, settings, @@ -463,6 +472,7 @@ impl Catalog { settings, available_editors, devices, + source_control, usage_model_aliases: environment .as_ref() .is_some_and(|environment| environment.capabilities.usage_model_aliases), @@ -528,6 +538,11 @@ impl Catalog { &self.devices } + /// The attached environment's Source control settings. + pub fn source_control(&self) -> &crate::settings_source_control::SourceControlConfig { + &self.source_control + } + /// The attached environment's Labs flags. pub fn labs_flags(&self) -> crate::settings_labs::ServerFlags { crate::settings_labs::ServerFlags { diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs index e56850cee651..fbfe523ecd0a 100644 --- a/apps/desktop-gpui/src/rpc.rs +++ b/apps/desktop-gpui/src/rpc.rs @@ -87,6 +87,24 @@ impl Method for DeviceList { type Success = Value; } +/// `server.discoverSourceControl`, the version control tools and hosting integrations on the server. +pub struct ServerDiscoverSourceControl; + +impl Method for ServerDiscoverSourceControl { + const TAG: &'static str = "server.discoverSourceControl"; + type Payload = Value; + type Success = Value; +} + +/// `server.checkSecretSource`, resolving a secret reference on the server. +pub struct ServerCheckSecretSource; + +impl Method for ServerCheckSecretSource { + const TAG: &'static str = "server.checkSecretSource"; + type Payload = Value; + type Success = Value; +} + /// `device.configure`, the hub and agent-access switches. pub struct DeviceConfigure; @@ -268,6 +286,35 @@ untyped_method!( ProviderConsumeResetCredit, "provider.consumeResetCredit" ); +untyped_method!( + /// `projects.listIgnoredFiles`: the files git ignores in a project, for the clone dialog. + ProjectsListIgnoredFiles, + "projects.listIgnoredFiles" +); +untyped_method!( + /// `projects.readFile`: a workspace file's text. + ProjectsReadFile, + "projects.readFile" +); +untyped_method!( + /// `projects.writeFile`: writes a workspace file. + ProjectsWriteFile, + "projects.writeFile" +); +untyped_method!( + /// `projectClone.start`: begins a clone and adds the project once it lands. + ProjectCloneStart, + "projectClone.start" +); +/// `subscribeProjectClones`, a stream of the clone list that stays open until interrupted. +pub struct SubscribeProjectClones; + +impl Method for SubscribeProjectClones { + const TAG: &'static str = "subscribeProjectClones"; + const STREAMING: bool = true; + type Payload = Value; + type Success = Value; +} /// `git.runStackedAction`, a stream of progress events ending with the action's result. pub struct GitRunStackedAction; diff --git a/apps/desktop-gpui/src/secret_source.rs b/apps/desktop-gpui/src/secret_source.rs new file mode 100644 index 000000000000..09f0d336185c --- /dev/null +++ b/apps/desktop-gpui/src/secret_source.rs @@ -0,0 +1,236 @@ +//! The secret sources a setting can read from (`SecretSourceInput.tsx`): the server's configured +//! secret providers and the reference syntax of each kind. + +use serde_json::{Map, Value, json}; + +/// `SECRET_REDACTED` (settingsSecrets.ts): six bullets stand in for a stored literal. +pub const REDACTED: &str = "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}"; + +/// One configured secret provider (`useSecretSources`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SecretSource { + pub id: String, + pub kind: String, + pub label: String, +} + +/// The providers a setting can point at: none unless the environment reads references. A kind the +/// app does not know is dropped, as the web drops a config that does not classify. +pub fn sources(settings: &Value, references: bool) -> Vec { + if !references { + return Vec::new(); + } + let Some(providers) = settings.get("secretProviders").and_then(Value::as_object) else { + return Vec::new(); + }; + providers + .iter() + .filter_map(|(id, config)| { + let kind = config.get("kind")?.as_str()?; + let label = config.get("label")?.as_str()?; + known(kind).then(|| SecretSource { + id: id.clone(), + kind: kind.to_owned(), + label: label.to_owned(), + }) + }) + .collect() +} + +fn known(kind: &str) -> bool { + matches!(kind, "one_password" | "openbao" | "aws_secrets_manager") +} + +/// `SettingsSecretValue` as stored: a reference is an object, anything else a literal. +pub fn reference_of(value: &Value) -> Option<(String, String)> { + let object = value.as_object()?; + Some(( + object.get("provider")?.as_str()?.to_owned(), + object.get("reference")?.as_str()?.to_owned(), + )) +} + +pub fn reference_value(provider: &str, reference: &str) -> Value { + json!({"provider": provider, "reference": reference}) +} + +/// Whether a stored value holds a secret (`hasToken`). +pub fn is_set(value: &Value) -> bool { + match value { + Value::String(text) => !text.is_empty(), + Value::Object(_) => true, + _ => false, + } +} + +/// `referencePlaceholder` of the provider kind. +pub fn placeholder(kind: &str) -> &'static str { + match kind { + "one_password" => "op://vault/item/field", + "openbao" => "path/to/secret#field", + "aws_secrets_manager" => "prod/openai#api_key", + _ => "", + } +} + +/// `invalidReferenceMessage` of the provider kind. +pub fn invalid_message(kind: &str) -> &'static str { + match kind { + "one_password" => "Use a 1Password reference like op://vault/item/field.", + "openbao" => "Use an OpenBao reference like path/to/secret#field.", + "aws_secrets_manager" => { + "Use an AWS Secrets Manager reference like name-or-arn or name-or-arn#key." + } + _ => "Enter a valid secret reference.", + } +} + +const MAX_REFERENCE: usize = 1024; + +fn plain(reference: &str) -> bool { + reference.chars().count() <= MAX_REFERENCE + && reference == reference.trim() + && !reference + .chars() + .any(|c| (c as u32) < 0x20 || c as u32 == 0x7f) +} + +/// `isReference` of the provider kind: syntax the kind can read. An unknown kind accepts any +/// non-empty reference, as the web does when the definition is missing. +pub fn is_reference(kind: &str, reference: &str) -> bool { + match kind { + "one_password" => one_password(reference), + "openbao" => plain(reference) && openbao(reference), + "aws_secrets_manager" => plain(reference) && aws(reference), + _ => !reference.is_empty(), + } +} + +/// `[A-Za-z0-9._-]` with spaces allowed inside, never at either end. +fn one_password_name(name: &str) -> bool { + let word = |c: char| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'); + let mut chars = name.chars(); + let (Some(first), Some(last)) = (chars.next(), name.chars().next_back()) else { + return false; + }; + word(first) && word(last) && name.chars().all(|c| word(c) || c == ' ') +} + +/// `^op://NAME(/NAME){2,3}(\?[a-z-]+=[A-Za-z0-9_-]+)?$`, at most 1024 characters. +fn one_password(reference: &str) -> bool { + if reference.chars().count() > MAX_REFERENCE { + return false; + } + let Some(rest) = reference.strip_prefix("op://") else { + return false; + }; + let (path, query) = match rest.split_once('?') { + Some((path, query)) => (path, Some(query)), + None => (rest, None), + }; + let names: Vec<&str> = path.split('/').collect(); + if !(3..=4).contains(&names.len()) || !names.iter().all(|name| one_password_name(name)) { + return false; + } + query.is_none_or(|query| { + query.split_once('=').is_some_and(|(key, value)| { + !key.is_empty() + && key.chars().all(|c| c.is_ascii_lowercase() || c == '-') + && !value.is_empty() + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-')) + }) + }) +} + +/// `path/to/secret#field`; the path never starts with `-`. +fn openbao(reference: &str) -> bool { + reference.split_once('#').is_some_and(|(path, field)| { + !path.is_empty() && !field.is_empty() && !path.starts_with('-') + }) +} + +/// `name-or-arn` or `name-or-arn#key`. +fn aws(reference: &str) -> bool { + let (id, key) = match reference.split_once('#') { + Some((id, key)) => (id, Some(key)), + None => (reference, None), + }; + !id.is_empty() && !id.starts_with('-') && key != Some("") +} + +/// A `Value` with the six-bullet marker, for a literal the server will store. +pub fn stored_literal() -> Value { + Value::String(REDACTED.to_owned()) +} + +/// The `server.checkSecretSource` payload. +pub fn check_payload(provider: &str, reference: &str) -> Value { + let mut source = Map::new(); + source.insert("provider".into(), json!(provider)); + source.insert("reference".into(), json!(reference)); + json!({"source": source}) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn one_password_references() { + for ok in [ + "op://vault/item/field", + "op://My Vault/item/section/field", + "op://vault/item/field?attribute=otp", + ] { + assert!(is_reference("one_password", ok), "{ok}"); + } + for bad in [ + "op://vault/item", + "op://vault/item/a/b/c", + "op:// vault/item/field", + "op://vault/item /field", + "vault/item/field", + "op://vault/item/field?Attr=x", + "op://vault/item/field?a=", + ] { + assert!(!is_reference("one_password", bad), "{bad}"); + } + } + + #[test] + fn openbao_and_aws_references() { + assert!(is_reference("openbao", "path/to/secret#field")); + assert!(!is_reference("openbao", "path/to/secret")); + assert!(!is_reference("openbao", "-path#field")); + assert!(!is_reference("openbao", " path#field")); + assert!(is_reference("aws_secrets_manager", "prod/openai")); + assert!(is_reference("aws_secrets_manager", "prod/openai#api_key")); + assert!(!is_reference("aws_secrets_manager", "prod/openai#")); + assert!(!is_reference("aws_secrets_manager", "-prod")); + } + + #[test] + fn only_known_providers_are_offered_and_only_when_references_are_read() { + let settings = json!({"secretProviders": { + "work": {"kind": "one_password", "label": "Work vault"}, + "odd": {"kind": "something_new", "label": "Odd"}, + }}); + assert!(sources(&settings, false).is_empty()); + let listed = sources(&settings, true); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].label, "Work vault"); + } + + #[test] + fn stored_values() { + assert!(!is_set(&json!(""))); + assert!(is_set(&json!(REDACTED))); + assert!(is_set(&reference_value("work", "op://a/b/c"))); + assert_eq!( + reference_of(&reference_value("work", "op://a/b/c")), + Some(("work".into(), "op://a/b/c".into())) + ); + } +} diff --git a/apps/desktop-gpui/src/settings.rs b/apps/desktop-gpui/src/settings.rs index f612c60ea22b..4739bb82ff83 100644 --- a/apps/desktop-gpui/src/settings.rs +++ b/apps/desktop-gpui/src/settings.rs @@ -666,6 +666,7 @@ pub fn searchable() -> &'static [SearchItem] { .chain(CHAT_ITEMS) .chain(crate::settings_typography::TYPOGRAPHY_ITEMS) .chain(crate::settings_devices::DEVICE_ITEMS) + .chain(crate::settings_source_control::SOURCE_CONTROL_ITEMS) .copied() .collect() }) diff --git a/apps/desktop-gpui/src/settings_appearance.rs b/apps/desktop-gpui/src/settings_appearance.rs index 9b312ad08e44..679dd4231e8a 100644 --- a/apps/desktop-gpui/src/settings_appearance.rs +++ b/apps/desktop-gpui/src/settings_appearance.rs @@ -438,7 +438,7 @@ impl ColorMode { } /// The built-in themes, in the web's card order (T3 Code first, then `BUILT_IN_THEMES`). -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum ThemeId { Default, T3Chat, @@ -479,7 +479,11 @@ impl Paint { /// OKLCH to gamma-encoded sRGB, clipped to the gamut (CSS Color 4 without gamut mapping). pub fn oklch_to_srgb(l: f32, c: f32, h: f32) -> [f32; 3] { let (sin, cos) = h.to_radians().sin_cos(); - let (a, b) = (c * cos, c * sin); + oklab_to_srgb([l, c * cos, c * sin]) +} + +/// OKLab to gamma-encoded sRGB, clipped to the gamut. +pub fn oklab_to_srgb([l, a, b]: [f32; 3]) -> [f32; 3] { let lms = [ l + 0.396_337_78 * a + 0.215_803_76 * b, l - 0.105_561_346 * a - 0.063_854_17 * b, @@ -501,6 +505,33 @@ pub fn oklch_to_srgb(l: f32, c: f32, h: f32) -> [f32; 3] { }) } +/// Gamma-encoded sRGB to OKLab. +pub fn srgb_to_oklab(rgb: [f32; 3]) -> [f32; 3] { + let [r, g, b] = rgb.map(|v| { + if v <= 0.040_45 { + v / 12.92 + } else { + ((v + 0.055) / 1.055).powf(2.4) + } + }); + let l = (0.412_221_47 * r + 0.536_332_55 * g + 0.051_445_995 * b).cbrt(); + let m = (0.211_903_5 * r + 0.680_699_5 * g + 0.107_396_96 * b).cbrt(); + let s = (0.088_302_46 * r + 0.281_718_85 * g + 0.629_978_7 * b).cbrt(); + [ + 0.210_454_26 * l + 0.793_617_8 * m - 0.004_072_047 * s, + 1.977_998_5 * l - 2.428_592_2 * m + 0.450_593_7 * s, + 0.025_904_037 * l + 0.782_771_77 * m - 0.808_675_77 * s, + ] +} + +/// `color-mix(in oklab, from, to weight)`: `weight` of `from` over the rest of `to`. +pub fn mix_oklab(from: [f32; 3], to: [f32; 3], weight: f32) -> [f32; 3] { + let (a, b) = (srgb_to_oklab(from), srgb_to_oklab(to)); + oklab_to_srgb(std::array::from_fn(|i| { + a[i] * weight + b[i] * (1. - weight) + })) +} + /// The three roles a theme orb paints from (`THEME_PREVIEW_RENDER_SPECS`): the canvas it tints, /// the accent that fills it, and the message action that glows opposite. #[derive(Clone, Copy, Debug)] @@ -510,6 +541,43 @@ pub struct OrbPalette { pub action: Paint, } +/// The extra roles `ThemeWireframePane` paints besides the orb's canvas and action. +#[derive(Clone, Copy, Debug)] +pub struct WireRoles { + pub sidebar: Paint, + pub surface: Paint, + pub accent_surface: Paint, + pub message_surface: Paint, +} + +const fn roles( + sidebar: [f32; 3], + surface: [f32; 3], + accent_surface: [f32; 3], + message_surface: [f32; 3], +) -> WireRoles { + WireRoles { + sidebar: Paint::Oklch(sidebar[0], sidebar[1], sidebar[2]), + surface: Paint::Oklch(surface[0], surface[1], surface[2]), + accent_surface: Paint::Oklch(accent_surface[0], accent_surface[1], accent_surface[2]), + message_surface: Paint::Oklch(message_surface[0], message_surface[1], message_surface[2]), + } +} + +const fn hex_roles( + sidebar: u32, + surface: u32, + accent_surface: u32, + message_surface: u32, +) -> WireRoles { + WireRoles { + sidebar: Paint::Hex(sidebar), + surface: Paint::Hex(surface), + accent_surface: Paint::Hex(accent_surface), + message_surface: Paint::Hex(message_surface), + } +} + const fn hex_orb(canvas: u32, accent: u32, action: u32) -> OrbPalette { OrbPalette { canvas: Paint::Hex(canvas), @@ -562,6 +630,74 @@ impl ThemeId { Self::ALL.iter().copied().find(|v| v.wire() == value) } + /// The wireframe roles for one half (themePalettes.ts, `getThemeCardDefinition`). + pub fn roles(self, dark: bool) -> WireRoles { + match (self, dark) { + (Self::Default, false) => hex_roles(0xfafafa, 0xffffff, 0xf4f4f5, 0xe4e4e7), + (Self::Default, true) => hex_roles(0x0f0f10, 0x121212, 0x27272a, 0x27272a), + (Self::T3Chat, false) => roles( + [0.928886, 0.031178, 322.592], + [0.971835, 0.012884, 321.894], + [0.939552, 0.024286, 321.664], + [0.926746, 0.037898, 332.6], + ), + (Self::T3Chat, true) => roles( + [0.185778, 0.019368, 322.159], + [0.267101, 0.02016, 311.799], + [0.364912, 0.050794, 308.491], + [0.273791, 0.025541, 309.079], + ), + (Self::Grove, false) => roles( + [0.936464, 0.014601, 163.554], + [0.972369, 0.005497, 157.15], + [0.909438, 0.021521, 164.612], + [0.891377, 0.026164, 164.929], + ), + (Self::Grove, true) => roles( + [0.309925, 0.032827, 160.944], + [0.260865, 0.02152, 162.75], + [0.437021, 0.060312, 158.962], + [0.470111, 0.067221, 158.676], + ), + (Self::Ocean, false) => roles( + [0.939254, 0.01193, 241.729], + [0.974199, 0.002856, 241.597], + [0.91295, 0.018827, 241.836], + [0.895373, 0.023469, 241.913], + ), + (Self::Ocean, true) => roles( + [0.290387, 0.032043, 247.274], + [0.242641, 0.024125, 250.573], + [0.413315, 0.051874, 243.855], + [0.445224, 0.056936, 243.413], + ), + (Self::Ember, false) => roles( + [0.942267, 0.01151, 50.785], + [0.976527, 0.002685, 60.725], + [0.916502, 0.01832, 49.597], + [0.899296, 0.022939, 49.163], + ), + (Self::Ember, true) => roles( + [0.293349, 0.029554, 46.882], + [0.245899, 0.019144, 42.044], + [0.416048, 0.055354, 50.484], + [0.447961, 0.061874, 50.849], + ), + (Self::Iris, false) => roles( + [0.941387, 0.014687, 300.474], + [0.976531, 0.003855, 303.226], + [0.914882, 0.022965, 299.986], + [0.897143, 0.028558, 299.758], + ), + (Self::Iris, true) => roles( + [0.266743, 0.044689, 294.138], + [0.225975, 0.031062, 293.741], + [0.372436, 0.07841, 294.204], + [0.399975, 0.086965, 294.177], + ), + } + } + /// The orb colors for one half (themePalettes.ts). pub fn orb(self, dark: bool) -> OrbPalette { match (self, dark) { @@ -710,11 +846,6 @@ impl ThemeChoice { } } -/// `color-mix(in srgb, from, to weight)`: `weight` of `from` over the rest of `to`. -pub fn mix(from: [f32; 3], to: [f32; 3], weight: f32) -> [f32; 3] { - std::array::from_fn(|i| from[i] * weight + to[i] * (1. - weight)) -} - const fn item(id: &'static str, title: &'static str, terms: &'static str) -> SearchItem { SearchItem { id, diff --git a/apps/desktop-gpui/src/settings_labs.rs b/apps/desktop-gpui/src/settings_labs.rs index 3beefaa9c195..1b711d3b8892 100644 --- a/apps/desktop-gpui/src/settings_labs.rs +++ b/apps/desktop-gpui/src/settings_labs.rs @@ -1117,6 +1117,7 @@ mod tests { root: format!("/home/user/projects/{key}"), logical_key: logical.map(str::to_owned), remote: Vec::new(), + repository: None, } } diff --git a/apps/desktop-gpui/src/settings_source_control.rs b/apps/desktop-gpui/src/settings_source_control.rs new file mode 100644 index 000000000000..62474458e3cb --- /dev/null +++ b/apps/desktop-gpui/src/settings_source_control.rs @@ -0,0 +1,1422 @@ +//! The Source control tab (`SourceControlSettings.tsx`, `SourceControlWritingSettings.tsx` and the +//! "Repositories" rows of `ProjectDefaultsSettings.tsx`): its server settings, the discovery +//! result that `server.discoverSourceControl` returns and the text of each discovery row. The +//! panel only draws this. +//! +//! Where each key lives (packages/contracts/src/settings.ts): +//! - `defaultAutoPull`, `removeAgentCreditsOnMerge`, `pullRequestMergeMethod`, +//! `sourceControlWritingStyle`, `reviewGuideInstructions` and the two model selections are +//! project-scoped: a project override lives in `projectSettingsOverrides[projectId]`, and the +//! effective value is the override, else the environment's, else the default. +//! - `reviewGuidesEnabled` is environment-wide. +//! - A writing style is an object whose three fields override one by one; the legacy string form +//! reads as custom instructions when it is not empty. + +use crate::settings::{SearchItem, Section}; +use crate::settings_devices::{self as devices, Layer, LayerKind, State}; +use serde_json::{Map, Value, json}; +use std::collections::{BTreeMap, HashMap}; + +/// Call tokens of the Source control tab's requests start here. +pub const TOKEN_BASE: u64 = 1 << 46; + +const fn item(id: &'static str, title: &'static str, terms: &'static str) -> SearchItem { + SearchItem { + id, + title, + section: Section::SourceControl, + terms, + desktop_only: false, + } +} + +/// `SOURCE_CONTROL_HOST_LABELS` (settingsSearch.ts:211). +macro_rules! host_labels { + () => { + "GitHub GitLab GitCafe Forgejo Bitbucket Azure DevOps" + }; +} + +/// `SOURCE_CONTROL_HOST_TERMS` (settingsSearch.ts:214): each host's label and search keywords. +macro_rules! host_terms { + () => { + "GitHub ghes enterprise GitLab GitCafe Forgejo gitea tea codeberg Bitbucket Azure DevOps" + }; +} + +/// The tab's `searchableSetting` entries (settingsSearch.ts:950-1050) and the hosts that have a +/// settings panel (`sourceControlHostSearchItems`, settingsSearch.ts:182-205). +pub const SOURCE_CONTROL_ITEMS: &[SearchItem] = &[ + item( + "automatic-pull", + "Automatically pull", + "auto pull default branch current checkout fast forward upstream", + ), + item( + "remove-agent-credits-on-merge", + "Remove agent credits when merging", + concat!( + "pull request ", + host_labels!(), + " squash co-authored-by attribution claude codex generated" + ), + ), + item( + "pull-request-merge-method", + "Default merge method", + "pull request merge squash rebase last selected", + ), + item( + "source-control", + "Source control", + concat!( + "version control git ", + host_terms!(), + " hosting integrations credentials scan server environment" + ), + ), + item( + "source-control-host-github", + "GitHub accounts and token", + "GitHub ghes enterprise gh account login user host switch multiple accounts disable sign in token personal access token pat api key credential", + ), + item( + "source-control-host-gitcafe", + "GitCafe credentials", + "GitCafe token token credentials sign in", + ), + item( + "source-control-host-bitbucket", + "Bitbucket credentials", + "Bitbucket accessToken email apiToken token credentials sign in", + ), + item( + "source-control-writing-style", + "Source control writing style", + "repository conventions conventional commits custom instructions change descriptions request titles", + ), + item( + "follow-change-request-templates", + "Follow change request templates", + "repository pr pull request description structure", + ), + item( + "source-control-writer-model", + "Source control writer model", + "override generated commit change request pr titles descriptions branch bookmark", + ), + item( + "review-guides", + "Review guides (beta)", + "pull request pr guided review guide tour beta experimental", + ), + item( + "review-guide-model", + "Review guide model", + "pull request pr guided review guide brief maintainer", + ), + item( + "review-guide-instructions", + "Review guide instructions", + "pull request pr guided review guide preferences taste prompt custom", + ), +]; + +/// A server setting of the tab. The three style fields are keys of `sourceControlWritingStyle`. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Field { + AutoPull, + AgentCredits, + MergeMethod, + StyleMode, + StyleInstructions, + FollowTemplates, + ReviewGuides, + ReviewInstructions, + ReviewModel, + WriterModel, +} + +impl Field { + /// The key in `settings` and in a project's override entry. + pub fn wire(self) -> &'static str { + match self { + Self::AutoPull => "defaultAutoPull", + Self::AgentCredits => "removeAgentCreditsOnMerge", + Self::MergeMethod => "pullRequestMergeMethod", + Self::StyleMode | Self::StyleInstructions | Self::FollowTemplates => { + "sourceControlWritingStyle" + } + Self::ReviewGuides => "reviewGuidesEnabled", + Self::ReviewInstructions => "reviewGuideInstructions", + Self::ReviewModel => "reviewGuideModelSelection", + Self::WriterModel => "sourceControlWriterModelSelection", + } + } + + /// The key inside the writing style object. + fn sub(self) -> Option<&'static str> { + match self { + Self::StyleMode => Some("mode"), + Self::StyleInstructions => Some("customInstructions"), + Self::FollowTemplates => Some("followChangeRequestTemplates"), + _ => None, + } + } + + /// `isProjectScopedSettingKey`: every key but `reviewGuidesEnabled`. + pub fn project_scoped(self) -> bool { + !matches!(self, Self::ReviewGuides) + } + + /// `DEFAULT_SERVER_SETTINGS`. + pub fn default_value(self) -> Value { + match self { + Self::AutoPull | Self::AgentCredits | Self::ReviewGuides => json!(false), + Self::FollowTemplates => json!(true), + Self::StyleMode => json!("repo_conventions"), + Self::StyleInstructions | Self::ReviewInstructions => json!(""), + Self::MergeMethod | Self::ReviewModel | Self::WriterModel => Value::Null, + } + } + + const ALL: [Self; 10] = [ + Self::AutoPull, + Self::AgentCredits, + Self::MergeMethod, + Self::StyleMode, + Self::StyleInstructions, + Self::FollowTemplates, + Self::ReviewGuides, + Self::ReviewInstructions, + Self::ReviewModel, + Self::WriterModel, + ]; +} + +/// The keys of `server.getConfig`'s `settings` that the tab reads, kept as the server sent them +/// so that a project override write carries the other keys of the entry along. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SourceControlConfig { + environment: Map, + overrides: HashMap>, + /// `sourceControlHosts`: each provider's token and host settings. + hosts: Map, + secret_sources: Vec, +} + +const TEXT_GENERATION: &str = "textGenerationModelSelection"; + +/// `createModelSelection`: the instance, the model and the options when there are any. +fn selection_of(value: &Value) -> Option { + let instance = value.get("instanceId")?.as_str()?; + let model = value.get("model")?.as_str()?; + let mut selection = json!({"instanceId": instance, "model": model}); + if let Some(options) = value + .get("options") + .filter(|o| o.as_array().is_some_and(|a| !a.is_empty())) + { + selection["options"] = options.clone(); + } + Some(selection) +} + +/// The model of a selection, for a row's value. +pub fn model_label(value: &Value) -> Option { + value.get("model")?.as_str().map(str::to_owned) +} + +fn style_part(value: &Value, field: Field) -> Option { + let sub = field.sub()?; + match value { + Value::Object(style) => style.get(sub).cloned(), + Value::String(legacy) => Some(match field { + Field::StyleMode => json!(if legacy.trim().is_empty() { + "repo_conventions" + } else { + "custom" + }), + Field::StyleInstructions => json!(legacy), + _ => json!(true), + }), + _ => None, + } +} + +fn read(map: &Map, field: Field) -> Option { + let value = map.get(field.wire())?; + if field.sub().is_some() { + style_part(value, field) + } else { + Some(value.clone()) + } +} + +/// `value` of `None` removes the key (and an emptied style object). +fn write(map: &mut Map, field: Field, value: Option) { + let Some(sub) = field.sub() else { + match value { + Some(value) => map.insert(field.wire().to_owned(), value), + None => map.remove(field.wire()), + }; + return; + }; + let mut style = match map.remove(field.wire()) { + Some(Value::Object(style)) => style, + Some(Value::String(legacy)) => { + let mut style = Map::new(); + if let Some(mode) = style_part(&Value::String(legacy.clone()), Field::StyleMode) { + style.insert("mode".to_owned(), mode); + } + style.insert("customInstructions".to_owned(), json!(legacy)); + style + } + _ => Map::new(), + }; + match value { + Some(value) => { + style.insert(sub.to_owned(), value); + } + None => { + style.remove(sub); + } + } + if !style.is_empty() { + map.insert(field.wire().to_owned(), Value::Object(style)); + } +} + +impl SourceControlConfig { + /// Reads the tab's keys out of a `settings` object; anything else gives the defaults. + pub fn from_settings(settings: &Value) -> Self { + let mut environment = Map::new(); + for key in Field::ALL + .iter() + .map(|field| field.wire()) + .chain([TEXT_GENERATION]) + { + if let Some(value) = settings.get(key) { + environment.insert(key.to_owned(), value.clone()); + } + } + let overrides = match settings.get("projectSettingsOverrides") { + Some(Value::Object(entries)) => entries + .iter() + .filter_map(|(id, entry)| Some((id.clone(), entry.as_object()?.clone()))) + .collect(), + _ => HashMap::new(), + }; + Self { + environment, + overrides, + hosts: settings + .get("sourceControlHosts") + .and_then(Value::as_object) + .cloned() + .unwrap_or_default(), + secret_sources: Vec::new(), + } + } + + /// The secret providers a setting can read from, once the environment reads references. + pub fn with_secret_sources(mut self, settings: &Value, references: bool) -> Self { + self.secret_sources = crate::secret_source::sources(settings, references); + self + } + + pub fn secret_sources(&self) -> &[crate::secret_source::SecretSource] { + &self.secret_sources + } + + /// One saved key of a provider's host settings (`sourceControlHosts[kind][key]`). + pub fn host_value(&self, kind: &str, key: &str) -> Value { + self.hosts + .get(kind) + .and_then(|entry| entry.get(key)) + .cloned() + .unwrap_or(Value::Null) + } + + /// `readHostCredentialSettings`: the saved token of each host. + pub fn tokens(&self, kind: &str) -> Vec<(String, Value)> { + let mut tokens: Vec<_> = self + .host_value(kind, "tokens") + .as_object() + .map(|map| map.iter().map(|(h, v)| (h.clone(), v.clone())).collect()) + .unwrap_or_default(); + tokens.sort_by(|a, b| a.0.cmp(&b.0)); + tokens + } + + /// `readHostCredentialSettings`: which hosts are on and the login each is pinned to. + pub fn host_choices(&self, kind: &str) -> BTreeMap { + self.host_value(kind, "hosts") + .as_object() + .map(|map| { + map.iter() + .map(|(host, entry)| { + ( + host.clone(), + HostChoice { + enabled: entry["enabled"].as_bool().unwrap_or(true), + account: text(&entry["account"]), + }, + ) + }) + .collect() + }) + .unwrap_or_default() + } + + /// The `settings.update` body for the keys of one provider's host settings. + pub fn host_patch(kind: &str, fields: &Map) -> Value { + json!({"patch": {"sourceControlHosts": {kind: fields}}}) + } + + /// Applies a host patch as the server would, so the tab shows it before the reply: an empty + /// string removes the key, and a typed secret shows as its redaction marker. + pub fn apply_host(&mut self, kind: &str, fields: &Map) { + let entry = self + .hosts + .entry(kind.to_owned()) + .or_insert_with(|| Value::Object(Map::new())); + let Some(entry) = entry.as_object_mut() else { + return; + }; + for (key, value) in fields { + match value { + Value::String(text) if text.is_empty() => { + entry.remove(key); + } + Value::Object(map) => { + let kept = map + .iter() + .filter(|(_, v)| v.as_str() != Some("")) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + entry.insert(key.clone(), Value::Object(kept)); + } + value => { + entry.insert(key.clone(), value.clone()); + } + } + } + } + + /// The environment's value, or the default when the server has none. + pub fn environment_value(&self, field: Field) -> Value { + read(&self.environment, field).unwrap_or_else(|| field.default_value()) + } + + /// Whether the environment holds a value other than the default (the layer reads as set). + pub fn environment_set(&self, field: Field) -> bool { + read(&self.environment, field).is_some_and(|value| value != field.default_value()) + } + + /// The first project of the group that overrides the key. + pub fn project_override(&self, field: Field, members: &[String]) -> Option { + if !field.project_scoped() { + return None; + } + members + .iter() + .find_map(|id| read(self.overrides.get(id)?, field)) + } + + /// `resolveProjectSettings` for one key: the override, else the environment's value. + pub fn effective(&self, field: Field, members: &[String], in_project: bool) -> Value { + if in_project && let Some(value) = self.project_override(field, members) { + return value; + } + self.environment_value(field) + } + + /// The text generation model the writer and review guide models start from: the project's + /// override, else the environment's `textGenerationModelSelection`, reduced to what a model + /// selection carries. + pub fn text_generation(&self, members: &[String], in_project: bool) -> Option { + let project = in_project + .then(|| { + members + .iter() + .find_map(|id| self.overrides.get(id)?.get(TEXT_GENERATION)) + }) + .flatten(); + let selection = project.or_else(|| self.environment.get(TEXT_GENERATION))?; + selection_of(selection) + } + + /// Puts the change in the environment's value, or in the entry of every member. + pub fn apply(&mut self, changes: &[(Field, Option)], members: Option<&[String]>) { + for (field, value) in changes { + match members { + Some(members) if field.project_scoped() => { + for id in members { + write( + self.overrides.entry(id.clone()).or_default(), + *field, + value.clone(), + ); + } + } + _ => write( + &mut self.environment, + *field, + Some(value.clone().unwrap_or_else(|| field.default_value())), + ), + } + } + } + + /// The `server.updateSettings` payload for the changes, as `apply` would make them. An + /// environment write sends only the changed keys; a project write replaces each entry. + pub fn patch(&self, changes: &[(Field, Option)], members: Option<&[String]>) -> Value { + match members { + Some(members) if changes.iter().all(|(field, _)| field.project_scoped()) => { + let mut after = self.clone(); + after.apply(changes, Some(members)); + devices::merge_override_patches( + members + .iter() + .map(|id| { + let entry = match after.overrides.get(id) { + Some(entry) if !entry.is_empty() => Value::Object(entry.clone()), + _ => Value::Null, + }; + json!({"patch": {"projectSettingsOverrides": {id: entry}}}) + }) + .collect(), + ) + } + _ => { + let mut patch = Map::new(); + for (field, value) in changes { + let value = value.clone().unwrap_or_else(|| field.default_value()); + match field.sub() { + Some(sub) => { + let style = patch + .entry(field.wire().to_owned()) + .or_insert_with(|| json!({})); + style[sub] = value; + } + None => { + patch.insert(field.wire().to_owned(), value); + } + } + } + json!({"patch": patch}) + } + } + } +} + +/// Why a control is inert, or that its write reaches the attached environment +/// (settingsLayout.tsx:405-448): the same rule as the Devices tab's two switches. +pub fn reach( + field: Field, + search: &crate::settings_labs::ScopeSearch, + data: &crate::settings_labs::ScopeData, +) -> Result<(), String> { + devices::reach(proxy(field), search, data) +} + +fn proxy(field: Field) -> devices::Key { + if field.project_scoped() { + devices::Key::Agent + } else { + devices::Key::Hub + } +} + +/// `mergeMethodLabel`: the select's options, "Last selected" first. +pub const MERGE_METHODS: [(&str, &str); 4] = [ + ("last", "Last selected"), + ("merge", "Merge"), + ("squash", "Squash and merge"), + ("rebase", "Rebase and merge"), +]; + +pub const STYLE_MODES: [(&str, &str); 3] = [ + ("repo_conventions", "Repository conventions"), + ("conventional_commits", "Conventional Commits"), + ("custom", "Custom instructions"), +]; + +/// The index of a `pullRequestMergeMethod` value in `MERGE_METHODS`. +pub fn merge_index(value: &Value) -> usize { + let wire = value.as_str().unwrap_or("last"); + MERGE_METHODS + .iter() + .position(|(key, _)| *key == wire) + .unwrap_or(0) +} + +pub fn style_index(value: &Value) -> usize { + let wire = value.as_str().unwrap_or("repo_conventions"); + STYLE_MODES + .iter() + .position(|(key, _)| *key == wire) + .unwrap_or(0) +} + +/// What the picked merge option writes: "Last selected" is `null`. +pub fn merge_value(index: usize) -> Value { + match MERGE_METHODS[index].0 { + "last" => Value::Null, + method => json!(method), + } +} + +/// The description under the writing style row. +pub fn style_description(mode: &str) -> &'static str { + match mode { + "conventional_commits" => { + "Use Conventional Commit prefixes and keep change request text concise." + } + "custom" => { + "Use your instructions for change descriptions and change requests in every project." + } + _ => "In each project, matches recent change descriptions and change request titles.", + } +} + +/// The value of a layer row. +fn describe(field: Field, value: &Value) -> String { + match field { + Field::MergeMethod => MERGE_METHODS[merge_index(value)].1.to_owned(), + Field::StyleMode => STYLE_MODES[style_index(value)].1.to_owned(), + _ => match value { + Value::Bool(true) => "On".to_owned(), + Value::Bool(false) => "Off".to_owned(), + Value::String(text) if text.is_empty() => "Off".to_owned(), + Value::String(text) => text.clone(), + Value::Object(_) => model_label(value).unwrap_or_else(|| "On".to_owned()), + Value::Null => "Off".to_owned(), + _ => "Inherits".to_owned(), + }, + } +} + +/// `settingInheritanceLayers` (SettingInheritance.tsx:105-147) for a key with any default. +pub fn layers( + field: Field, + environment_label: &str, + in_project: bool, + project_override: Option<&Value>, + config: &SourceControlConfig, +) -> Vec { + let from_project = field.project_scoped() && project_override.is_some(); + let environment_set = config.environment_set(field); + let mut out = Vec::new(); + if in_project && field.project_scoped() { + out.push(Layer { + kind: LayerKind::Project, + label: "Project".to_owned(), + value: project_override + .map_or_else(|| "Inherits".to_owned(), |value| describe(field, value)), + effective: from_project, + set: from_project, + }); + } + out.push(Layer { + kind: LayerKind::Environment, + label: environment_label.to_owned(), + value: if environment_set { + describe(field, &config.environment_value(field)) + } else { + "Inherits".to_owned() + }, + effective: !from_project && environment_set, + set: environment_set, + }); + out.push(Layer { + kind: LayerKind::Default, + label: "Default".to_owned(), + value: describe(field, &field.default_value()), + effective: !from_project && !environment_set, + set: true, + }); + out +} + +/// The tooltip and marker state of a row (settingsLayout.tsx:381-395). +pub fn inheritance( + field: Field, + in_project: bool, + has_override: bool, + environment_set: bool, + environment_name: &str, +) -> (State, String) { + devices::inheritance( + proxy(field), + in_project, + has_override.then_some(true), + environment_set, + environment_name, + ) +} + +/// `NullOr(String)` in the wire's two shapes: a plain string or null, and effect's `Option` +/// (`{"_tag":"Some","value":...}`). +fn text(value: &Value) -> Option { + let value = match value { + Value::Object(option) if option.get("_tag").and_then(Value::as_str) == Some("Some") => { + option.get("value")? + } + other => other, + }; + value + .as_str() + .map(str::trim) + .filter(|text| !text.is_empty()) + .map(str::to_owned) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AuthStatus { + Authenticated, + Unauthenticated, + Unknown, +} + +/// `SourceControlProviderAuth` (sourceControl.ts:115-135), reduced to what a row reads. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Auth { + pub status: AuthStatus, + pub account: Option, + pub detail: Option, + pub accounts: Vec, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthAccount { + pub host: String, + pub account: String, + pub active: bool, + pub authenticated: bool, + pub environment_variable: Option, + pub error: Option, +} + +/// Whether the provider has an expanded panel (`definition.settings` in the web's registry). +pub fn has_host_settings(kind: &str) -> bool { + matches!(kind, "github" | "gitcafe" | "bitbucket") +} + +/// `HostCredentialSettings["hosts"][host]`: whether the host is on, and the login it is pinned to. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostChoice { + pub enabled: bool, + pub account: Option, +} + +/// `HostAccountGroup` (SourceControlAccountSettings.logic.ts). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostAccountGroup { + pub host: String, + pub active_account: Option, + pub selectable: Vec, + pub broken: Vec, + pub environment_variable: Option, +} + +/// `groupHostAccounts`. +pub fn group_host_accounts(accounts: &[AuthAccount]) -> Vec { + let mut hosts: Vec<&str> = Vec::new(); + for entry in accounts { + if !hosts.contains(&entry.host.as_str()) { + hosts.push(&entry.host); + } + } + hosts + .into_iter() + .map(|host| { + let entries: Vec<&AuthAccount> = accounts.iter().filter(|e| e.host == host).collect(); + let stored: Vec<&&AuthAccount> = entries + .iter() + .filter(|e| e.environment_variable.is_none()) + .collect(); + let selectable: Vec<&&AuthAccount> = + stored.iter().copied().filter(|e| e.authenticated).collect(); + HostAccountGroup { + host: host.to_owned(), + active_account: selectable + .iter() + .find(|e| e.active) + .or(selectable.first()) + .map(|e| e.account.clone()), + selectable: selectable.iter().map(|e| e.account.clone()).collect(), + broken: stored + .iter() + .filter(|e| !e.authenticated) + .map(|e| (***e).clone()) + .collect(), + environment_variable: entries.iter().find_map(|e| e.environment_variable.clone()), + } + }) + .collect() +} + +/// `nextHostAccountChoices`: a host that is on and follows the active login is not stored. +pub fn next_host_account_choices( + current: &BTreeMap, + host: &str, + enabled: Option, + account: Option>, +) -> BTreeMap { + let previous = current.get(host); + let enabled = enabled.or(previous.map(|p| p.enabled)).unwrap_or(true); + let account = match account { + None => previous.and_then(|p| p.account.clone()), + Some(next) => next.map(str::to_owned), + }; + let mut rest = current.clone(); + rest.remove(host); + if !(enabled && account.is_none()) { + rest.insert(host.to_owned(), HostChoice { enabled, account }); + } + rest +} + +/// The `hosts` map as the settings patch carries it. +pub fn host_choices_value(choices: &BTreeMap) -> Value { + Value::Object( + choices + .iter() + .map(|(host, choice)| { + let mut entry = Map::new(); + entry.insert("enabled".into(), json!(choice.enabled)); + if let Some(account) = &choice.account { + entry.insert("account".into(), json!(account)); + } + (host.clone(), Value::Object(entry)) + }) + .collect(), + ) +} + +/// A `VcsDiscoveryItem` or a `SourceControlProviderDiscoveryItem`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Item { + pub kind: String, + pub label: String, + pub executable: Option, + pub available: bool, + pub version: Option, + pub install_hint: String, + /// A version control system the server does not drive yet (`implemented: false`). + pub implemented: bool, + /// Present for a provider. + pub auth: Option, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Discovery { + pub version_control: Vec, + pub providers: Vec, +} + +impl Discovery { + pub fn is_empty(&self) -> bool { + self.version_control.is_empty() && self.providers.is_empty() + } +} + +fn item_of(value: &Value) -> Option { + let auth = value.get("auth").map(|auth| Auth { + status: match auth["status"].as_str() { + Some("authenticated") => AuthStatus::Authenticated, + Some("unauthenticated") => AuthStatus::Unauthenticated, + _ => AuthStatus::Unknown, + }, + account: text(&auth["account"]), + detail: text(&auth["detail"]), + accounts: auth["accounts"] + .as_array() + .map(|accounts| { + accounts + .iter() + .filter_map(|entry| { + Some(AuthAccount { + host: entry["host"].as_str()?.to_owned(), + account: entry["account"].as_str()?.to_owned(), + active: entry["active"].as_bool().unwrap_or(false), + authenticated: entry["authenticated"].as_bool().unwrap_or(false), + environment_variable: text(&entry["environmentVariable"]), + error: text(&entry["error"]), + }) + }) + .collect() + }) + .unwrap_or_default(), + }); + Some(Item { + kind: value["kind"].as_str()?.to_owned(), + label: value["label"].as_str()?.to_owned(), + executable: text(&value["executable"]), + available: value["status"].as_str() == Some("available"), + version: text(&value["version"]), + install_hint: value["installHint"].as_str().unwrap_or_default().to_owned(), + implemented: value["implemented"].as_bool().unwrap_or(true), + auth, + }) +} + +impl Discovery { + /// Reads a `SourceControlDiscoveryResult`; an item that does not decode is dropped. + pub fn parse(value: &Value) -> Self { + let items = |key: &str| -> Vec { + value[key] + .as_array() + .map(|items| items.iter().filter_map(item_of).collect()) + .unwrap_or_default() + }; + Self { + version_control: items("versionControlSystems"), + providers: items("sourceControlProviders"), + } + } +} + +impl Item { + /// `isVcsNotReady`. + pub fn not_ready(&self) -> bool { + self.auth.is_none() && !self.implemented + } + + /// The switch of the row, which only reports (`enabled` in `DiscoveryItemRow`). + pub fn enabled(&self) -> bool { + match &self.auth { + Some(auth) => self.available && auth.status == AuthStatus::Authenticated, + None => self.available && self.implemented, + } + } + + pub fn dot(&self) -> Dot { + if self.not_ready() { + return Dot::Muted; + } + match &self.auth { + Some(auth) if self.available && auth.status == AuthStatus::Authenticated => { + Dot::Success + } + Some(_) => Dot::Muted, + None if !self.available => Dot::Warning, + None => Dot::Success, + } + } + + /// The badge after the version: "Coming Soon", or "Not authenticated". + pub fn badge(&self) -> Option { + if self.not_ready() { + return Some(Badge::ComingSoon); + } + (self.auth.as_ref()?.status == AuthStatus::Unauthenticated) + .then_some(Badge::NotAuthenticated) + } + + /// `itemSummary`: one piece per flex child of the row's summary line. + pub fn summary(&self) -> Vec { + let text = |text: String| vec![Span::Text(text)]; + if self.not_ready() { + return text(format!("Support for {} is coming soon.", self.label)); + } + if !self.available { + return text(format!( + "Not available on this server: {}", + self.install_hint + )); + } + let Some(auth) = &self.auth else { + return text("Available".to_owned()); + }; + match auth.status { + AuthStatus::Authenticated => { + let mut spans = vec![Span::Text("Authenticated".to_owned())]; + if let Some(account) = &auth.account { + spans.push(Span::Text("as".to_owned())); + spans.push(Span::Account(account.clone())); + } + if let Some(detail) = &auth.detail { + spans.push(Span::Text(format!("\u{b7} {detail}"))); + } + spans + } + AuthStatus::Unauthenticated if self.executable.is_none() => { + text(format!("Available. {}", self.install_hint)) + } + AuthStatus::Unauthenticated if auth.accounts.iter().any(|a| a.authenticated) => text( + auth.detail + .clone() + .unwrap_or_else(|| format!("Every {} host is turned off.", self.label)), + ), + AuthStatus::Unauthenticated => vec![ + Span::Text(format!( + "{} is not authenticated on this server. Sign in or configure credentials using the", + self.label + )), + Span::Code(self.executable.clone().unwrap_or_default()), + Span::Text("tool on the server host to enable change request features.".to_owned()), + ], + AuthStatus::Unknown => text(format!( + "Could not verify {}. {}", + self.label, + auth.detail.as_deref().unwrap_or(&self.install_hint) + )), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dot { + Success, + Warning, + Muted, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Badge { + ComingSoon, + NotAuthenticated, +} + +impl Badge { + pub fn label(self) -> &'static str { + match self { + Self::ComingSoon => "Coming Soon", + Self::NotAuthenticated => "Not authenticated", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Span { + Text(String), + Code(String), + /// An account name, redacted until clicked. + Account(String), +} + +/// `redactedPlaceholder` (RedactedSensitiveText.tsx:7-24): the same letters for the same name. +pub fn redacted(value: &str) -> String { + const ALPHABET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789"; + let mut state: i32 = 0x811c9dc5_u32 as i32; + for unit in value.encode_utf16() { + state ^= i32::from(unit); + state = state.wrapping_mul(0x0100_0193); + } + let mut next = || { + state = (state ^ ((state as u32) >> 13) as i32).wrapping_mul(0x85eb_ca6b_u32 as i32); + state = (state ^ ((state as u32) >> 16) as i32).wrapping_mul(0xc2b2_ae35_u32 as i32); + ALPHABET[(i64::from(state).unsigned_abs() % ALPHABET.len() as u64) as usize] as char + }; + value + .chars() + .map(|c| { + if matches!(c, '@' | '.' | '-' | '_') { + c + } else { + next() + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(members: &[&str]) -> Vec { + members.iter().map(|id| (*id).to_owned()).collect() + } + + #[test] + fn defaults_when_the_server_sends_nothing() { + let config = SourceControlConfig::from_settings(&json!({})); + assert_eq!(config.environment_value(Field::AutoPull), json!(false)); + assert_eq!(config.environment_value(Field::MergeMethod), Value::Null); + assert_eq!( + config.environment_value(Field::FollowTemplates), + json!(true) + ); + assert_eq!( + config.environment_value(Field::StyleMode), + json!("repo_conventions") + ); + assert!(!config.environment_set(Field::FollowTemplates)); + } + + #[test] + fn a_legacy_style_string_reads_as_custom_instructions() { + let config = + SourceControlConfig::from_settings(&json!({"sourceControlWritingStyle": "Be brief."})); + assert_eq!(config.environment_value(Field::StyleMode), json!("custom")); + assert_eq!( + config.environment_value(Field::StyleInstructions), + json!("Be brief.") + ); + let empty = SourceControlConfig::from_settings(&json!({"sourceControlWritingStyle": " "})); + assert_eq!( + empty.environment_value(Field::StyleMode), + json!("repo_conventions") + ); + } + + #[test] + fn a_project_override_wins_and_style_fields_override_one_by_one() { + let config = SourceControlConfig::from_settings(&json!({ + "defaultAutoPull": true, + "sourceControlWritingStyle": {"mode": "conventional_commits", "customInstructions": "x", "followChangeRequestTemplates": true}, + "projectSettingsOverrides": {"p1": {"defaultAutoPull": false, "sourceControlWritingStyle": {"mode": "custom"}}} + })); + let members = ids(&["p1"]); + assert_eq!( + config.effective(Field::AutoPull, &members, true), + json!(false) + ); + assert_eq!( + config.effective(Field::AutoPull, &members, false), + json!(true) + ); + assert_eq!( + config.effective(Field::StyleMode, &members, true), + json!("custom") + ); + assert_eq!( + config.effective(Field::StyleInstructions, &members, true), + json!("x") + ); + } + + #[test] + fn a_null_override_is_an_override_to_last_selected() { + let config = SourceControlConfig::from_settings(&json!({ + "pullRequestMergeMethod": "squash", + "projectSettingsOverrides": {"p1": {"pullRequestMergeMethod": null}} + })); + let members = ids(&["p1"]); + assert_eq!( + config.project_override(Field::MergeMethod, &members), + Some(Value::Null) + ); + assert_eq!( + config.effective(Field::MergeMethod, &members, true), + Value::Null + ); + assert_eq!( + config.effective(Field::MergeMethod, &[], false), + json!("squash") + ); + } + + #[test] + fn an_environment_write_sends_only_the_changed_style_field() { + let config = SourceControlConfig::default(); + let patch = config.patch( + &[ + (Field::StyleMode, Some(json!("custom"))), + (Field::AutoPull, Some(json!(true))), + ], + None, + ); + assert_eq!( + patch, + json!({"patch": {"sourceControlWritingStyle": {"mode": "custom"}, "defaultAutoPull": true}}) + ); + } + + #[test] + fn resetting_in_the_environment_writes_the_default() { + let config = SourceControlConfig::default(); + let patch = config.patch( + &[(Field::MergeMethod, None), (Field::FollowTemplates, None)], + None, + ); + assert_eq!( + patch, + json!({"patch": {"pullRequestMergeMethod": null, "sourceControlWritingStyle": {"followChangeRequestTemplates": true}}}) + ); + } + + #[test] + fn a_project_write_replaces_the_entry_and_keeps_its_other_keys() { + let config = SourceControlConfig::from_settings(&json!({ + "projectSettingsOverrides": {"p1": {"enableAgentDeviceAccess": true}} + })); + let patch = config.patch(&[(Field::AutoPull, Some(json!(true)))], Some(&ids(&["p1"]))); + assert_eq!( + patch, + json!({"patch": {"projectSettingsOverrides": {"p1": {"enableAgentDeviceAccess": true, "defaultAutoPull": true}}}}) + ); + } + + #[test] + fn clearing_the_last_override_of_a_project_writes_null() { + let config = SourceControlConfig::from_settings(&json!({ + "projectSettingsOverrides": {"p1": {"defaultAutoPull": true}} + })); + let patch = config.patch(&[(Field::AutoPull, None)], Some(&ids(&["p1"]))); + assert_eq!( + patch, + json!({"patch": {"projectSettingsOverrides": {"p1": null}}}) + ); + } + + #[test] + fn an_environment_wide_key_ignores_the_project() { + let mut config = SourceControlConfig::default(); + config.apply( + &[(Field::ReviewGuides, Some(json!(true)))], + Some(&ids(&["p1"])), + ); + assert_eq!(config.environment_value(Field::ReviewGuides), json!(true)); + assert_eq!( + config.project_override(Field::ReviewGuides, &ids(&["p1"])), + None + ); + } + + #[test] + fn apply_puts_style_fields_in_the_override_entry() { + let mut config = SourceControlConfig::default(); + let members = ids(&["p1"]); + config.apply(&[(Field::StyleMode, Some(json!("custom")))], Some(&members)); + assert_eq!( + config.project_override(Field::StyleMode, &members), + Some(json!("custom")) + ); + config.apply(&[(Field::StyleMode, None)], Some(&members)); + assert_eq!(config.project_override(Field::StyleMode, &members), None); + } + + #[test] + fn merge_options_map_to_wire_values() { + assert_eq!(merge_value(0), Value::Null); + assert_eq!(merge_value(2), json!("squash")); + assert_eq!(merge_index(&Value::Null), 0); + assert_eq!(merge_index(&json!("rebase")), 3); + assert_eq!(style_index(&json!("custom")), 2); + } + + #[test] + fn layers_mark_the_effective_one() { + let config = SourceControlConfig::from_settings(&json!({"defaultAutoPull": true})); + let layers = layers(Field::AutoPull, "Mac", true, None, &config); + assert_eq!(layers.len(), 3); + assert!(layers[1].effective && layers[1].value == "On"); + assert!(!layers[2].effective && layers[2].value == "Off"); + let (state, _) = inheritance(Field::AutoPull, true, false, true, "Mac"); + assert_eq!(state, State::Inherited); + } + + fn discovery() -> Discovery { + Discovery::parse(&json!({ + "versionControlSystems": [ + {"kind": "git", "label": "Git", "executable": "git", "status": "available", "version": "2.51.0", "installHint": "Install Git", "implemented": true}, + {"kind": "jj", "label": "Jujutsu", "executable": null, "status": "missing", "version": {"_tag": "None"}, "installHint": "Install jj", "implemented": false} + ], + "sourceControlProviders": [ + {"kind": "github", "label": "GitHub", "executable": "gh", "status": "available", "version": {"_tag": "Some", "value": "2.80.0"}, "installHint": "Install gh", + "auth": {"status": "authenticated", "account": {"_tag": "Some", "value": "octo"}, "host": null, "detail": null}}, + {"kind": "gitlab", "label": "GitLab", "executable": "glab", "status": "available", "version": null, "installHint": "Install glab", + "auth": {"status": "unauthenticated", "account": null, "host": null, "detail": null}}, + {"kind": "bitbucket", "label": "Bitbucket", "executable": null, "status": "available", "version": null, "installHint": "Add a token.", + "auth": {"status": "unauthenticated", "account": null, "host": null, "detail": null}}, + {"kind": "forgejo", "label": "Forgejo / Gitea", "executable": "tea", "status": "missing", "version": null, "installHint": "Install tea", + "auth": {"status": "unknown", "account": null, "host": null, "detail": null}} + ] + })) + } + + #[test] + fn discovery_parses_both_option_shapes() { + let found = discovery(); + assert_eq!(found.version_control.len(), 2); + assert_eq!(found.version_control[0].version.as_deref(), Some("2.51.0")); + assert_eq!(found.version_control[1].version, None); + assert_eq!(found.providers[0].version.as_deref(), Some("2.80.0")); + assert_eq!( + found.providers[0] + .auth + .as_ref() + .and_then(|a| a.account.as_deref()), + Some("octo") + ); + assert!(Discovery::parse(&json!({})).is_empty()); + } + + #[test] + fn rows_report_dots_badges_and_switches() { + let found = discovery(); + let [git, jj] = &found.version_control[..] else { + panic!() + }; + assert_eq!( + (git.dot(), git.enabled(), git.badge()), + (Dot::Success, true, None) + ); + assert_eq!( + (jj.dot(), jj.badge()), + (Dot::Muted, Some(Badge::ComingSoon)) + ); + let [github, gitlab, _, forgejo] = &found.providers[..] else { + panic!() + }; + assert_eq!((github.dot(), github.enabled()), (Dot::Success, true)); + assert_eq!( + (gitlab.dot(), gitlab.badge()), + (Dot::Muted, Some(Badge::NotAuthenticated)) + ); + assert!(!forgejo.enabled()); + } + + #[test] + fn a_missing_version_control_tool_warns() { + let item = Item { + kind: "git".into(), + label: "Git".into(), + executable: Some("git".into()), + available: false, + version: None, + install_hint: "Install Git".into(), + implemented: true, + auth: None, + }; + assert_eq!(item.dot(), Dot::Warning); + } + + #[test] + fn summaries_follow_the_web_order() { + let found = discovery(); + assert_eq!( + found.version_control[1].summary(), + vec![Span::Text("Support for Jujutsu is coming soon.".into())] + ); + assert_eq!( + found.providers[0].summary(), + vec![ + Span::Text("Authenticated".into()), + Span::Text("as".into()), + Span::Account("octo".into()) + ] + ); + assert_eq!(found.providers[1].summary()[1], Span::Code("glab".into())); + assert_eq!( + found.providers[2].summary(), + vec![Span::Text("Available. Add a token.".into())] + ); + assert_eq!( + found.providers[3].summary(), + vec![Span::Text( + "Not available on this server: Install tea".into() + )] + ); + assert_eq!( + found.version_control[0].summary(), + vec![Span::Text("Available".into())] + ); + } + + #[test] + fn redaction_keeps_punctuation_and_is_stable() { + let masked = redacted("a.b-c@d_e"); + assert_eq!(masked.len(), 9); + assert_eq!( + masked + .chars() + .filter(|c| matches!(c, '.' | '-' | '@' | '_')) + .count(), + 4 + ); + assert_eq!(masked, redacted("a.b-c@d_e")); + assert_ne!(redacted("octocat"), "octocat"); + } + + fn login(host: &str, account: &str, active: bool, ok: bool, env: Option<&str>) -> AuthAccount { + AuthAccount { + host: host.to_owned(), + account: account.to_owned(), + active, + authenticated: ok, + environment_variable: env.map(str::to_owned), + error: None, + } + } + + #[test] + fn groups_logins_by_host() { + let groups = group_host_accounts(&[ + login("github.com", "ada", false, true, None), + login("github.com", "bob", true, true, None), + login("github.com", "old", false, false, None), + login( + "ghe.example", + "eve", + false, + true, + Some("GH_ENTERPRISE_TOKEN"), + ), + ]); + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].selectable, ids(&["ada", "bob"])); + assert_eq!(groups[0].active_account.as_deref(), Some("bob")); + assert_eq!(groups[0].broken.len(), 1); + assert!(groups[1].selectable.is_empty()); + assert_eq!( + groups[1].environment_variable.as_deref(), + Some("GH_ENTERPRISE_TOKEN") + ); + } + + #[test] + fn host_choices_drop_the_default_and_keep_the_rest() { + let mut current = BTreeMap::new(); + let off = next_host_account_choices(¤t, "github.com", Some(false), None); + assert_eq!( + off["github.com"], + HostChoice { + enabled: false, + account: None + } + ); + let pinned = next_host_account_choices(&off, "github.com", Some(true), Some(Some("ada"))); + assert_eq!(pinned["github.com"].account.as_deref(), Some("ada")); + current = pinned; + let back = next_host_account_choices(¤t, "github.com", None, Some(None)); + assert!(back.is_empty()); + } + + #[test] + fn applying_a_host_patch_drops_cleared_keys() { + let mut config = SourceControlConfig::default(); + let fields = |value: Value| match value { + Value::Object(map) => map, + _ => Map::new(), + }; + config.apply_host( + "bitbucket", + &fields(json!({"email": "a@b.c", "accessToken": "******"})), + ); + assert_eq!(config.host_value("bitbucket", "email"), json!("a@b.c")); + config.apply_host("bitbucket", &fields(json!({"email": ""}))); + assert_eq!(config.host_value("bitbucket", "email"), Value::Null); + assert_eq!( + config.host_value("bitbucket", "accessToken"), + json!("******") + ); + } + + #[test] + fn redacted_keeps_separators_and_length() { + let shown = redacted("ada-lovelace@x.io"); + assert_eq!(shown.chars().count(), 17); + for (a, b) in "ada-lovelace@x.io".chars().zip(shown.chars()) { + if matches!(a, '@' | '.' | '-' | '_') { + assert_eq!(a, b); + } else { + assert!(b.is_ascii_alphanumeric()); + } + } + assert_eq!(shown, redacted("ada-lovelace@x.io")); + } +} diff --git a/apps/desktop-gpui/src/source-control-icons/aws_secrets_manager.svg b/apps/desktop-gpui/src/source-control-icons/aws_secrets_manager.svg new file mode 100644 index 000000000000..ff66d550871e --- /dev/null +++ b/apps/desktop-gpui/src/source-control-icons/aws_secrets_manager.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/source-control-icons/azure-devops.svg b/apps/desktop-gpui/src/source-control-icons/azure-devops.svg new file mode 100644 index 000000000000..1ec9bf52e930 --- /dev/null +++ b/apps/desktop-gpui/src/source-control-icons/azure-devops.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/source-control-icons/bitbucket.svg b/apps/desktop-gpui/src/source-control-icons/bitbucket.svg new file mode 100644 index 000000000000..979485a81ddb --- /dev/null +++ b/apps/desktop-gpui/src/source-control-icons/bitbucket.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/source-control-icons/forgejo.svg b/apps/desktop-gpui/src/source-control-icons/forgejo.svg new file mode 100644 index 000000000000..bc0008725f8a --- /dev/null +++ b/apps/desktop-gpui/src/source-control-icons/forgejo.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/source-control-icons/git.svg b/apps/desktop-gpui/src/source-control-icons/git.svg new file mode 100644 index 000000000000..0ea4e83eb9f8 --- /dev/null +++ b/apps/desktop-gpui/src/source-control-icons/git.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/source-control-icons/gitcafe.svg b/apps/desktop-gpui/src/source-control-icons/gitcafe.svg new file mode 100644 index 000000000000..5a3f10b67e25 --- /dev/null +++ b/apps/desktop-gpui/src/source-control-icons/gitcafe.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/source-control-icons/github-dark.svg b/apps/desktop-gpui/src/source-control-icons/github-dark.svg new file mode 100644 index 000000000000..358a2e0d577a --- /dev/null +++ b/apps/desktop-gpui/src/source-control-icons/github-dark.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/source-control-icons/github-light.svg b/apps/desktop-gpui/src/source-control-icons/github-light.svg new file mode 100644 index 000000000000..46d65a26a9a1 --- /dev/null +++ b/apps/desktop-gpui/src/source-control-icons/github-light.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/source-control-icons/gitlab.svg b/apps/desktop-gpui/src/source-control-icons/gitlab.svg new file mode 100644 index 000000000000..5759100cf2e7 --- /dev/null +++ b/apps/desktop-gpui/src/source-control-icons/gitlab.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/source-control-icons/jj.svg b/apps/desktop-gpui/src/source-control-icons/jj.svg new file mode 100644 index 000000000000..5f5f38b03792 --- /dev/null +++ b/apps/desktop-gpui/src/source-control-icons/jj.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/source-control-icons/one_password.svg b/apps/desktop-gpui/src/source-control-icons/one_password.svg new file mode 100644 index 000000000000..845f90830260 --- /dev/null +++ b/apps/desktop-gpui/src/source-control-icons/one_password.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/source-control-icons/openbao.svg b/apps/desktop-gpui/src/source-control-icons/openbao.svg new file mode 100644 index 000000000000..1461d0c2f821 --- /dev/null +++ b/apps/desktop-gpui/src/source-control-icons/openbao.svg @@ -0,0 +1 @@ + diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index 0fd39488fedb..7118dca5f33c 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -11,7 +11,7 @@ use crate::wire::generated::{ThreadsEntryLastErrorClass, ThreadsEntryStatus}; use std::fmt; /// What the window shows about the managed server and its socket. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq)] pub enum Status { NotConnected, Starting, diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 2108d0b53e40..5ed8853e51c4 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -56,6 +56,7 @@ use std::{ const TERMINAL_SIDEBAR_WIDTH: f32 = 144.; /// The drag handle on the terminal drawer's top edge. const TERMINAL_HANDLE_HEIGHT: f32 = 8.; +mod add_to_machine; mod composer_menu; mod model_popover; mod right_panel; @@ -64,6 +65,7 @@ mod settings; mod usage_dialog; mod workspace_menu; +pub use add_to_machine::bindings as add_to_machine_bindings; pub use settings::bindings as settings_bindings; pub use usage_dialog::bindings as usage_bindings; @@ -294,6 +296,7 @@ pub struct MainView { card: Entity, toasts: Entity, usage: Entity, + add_to_machine: Entity, settings: Entity, /// The slow requests toast; kept after the user dismisses it, like the web coordinator. slow_toast: Option, @@ -398,6 +401,27 @@ impl MainView { |this, _, event: &branch_picker::Event, cx| this.on_branch_event(event, cx), ) .detach(); + let add_to_machine = + cx.new(|cx| add_to_machine::AddToMachine::new(actions.clone(), window, cx)); + cx.subscribe( + &add_to_machine, + |this, _, notice: &add_to_machine::Notice, cx| { + let kind = if notice.error { + ToastKind::Error + } else { + ToastKind::Info + }; + let toast = Toast::new(notice.title.clone()).kind(kind); + let toast = match ¬ice.description { + Some(description) => toast.description(description.clone()), + None => toast, + }; + this.toasts.update(cx, |toasts, cx| { + toasts.add(toast, cx); + }); + }, + ) + .detach(); let card = cx.new(|cx| DetailsCard::new(actions.clone(), window, cx)); cx.subscribe(&card, |this, _, _: &OpenDiff, cx| this.open_diff(cx)) .detach(); @@ -468,6 +492,7 @@ impl MainView { card, toasts: cx.new(|cx| ToastHost::new(TOPBAR_HEIGHT, window, cx)), usage: cx.new(|cx| usage_dialog::UsageDialog::new(actions.clone(), cx)), + add_to_machine, settings: cx.new(|cx| settings::SettingsDialog::new(actions.clone(), window, cx)), slow_toast: None, main_width: Cell::new(0.), @@ -533,12 +558,18 @@ impl MainView { ( c.devices().clone(), crate::settings_devices::project_ids(&entries), + c.source_control().secret_sources().to_vec(), ) }); + let source_control = self.providers.as_ref().map(|c| c.source_control().clone()); + let project_ids = crate::settings_devices::project_ids(&entries); self.settings.update(cx, |settings, cx| { settings.set_labs_data(data, flags, cx); - if let Some((config, projects)) = devices { - settings.set_devices_data(config, projects, cx); + if let Some((config, projects, sources)) = devices { + settings.set_devices_data(config, projects, sources, cx); + } + if let Some(config) = source_control { + settings.set_source_control_data(config, project_ids, cx); } }); } @@ -611,6 +642,17 @@ impl MainView { return cx.notify(); } if let Status::Call { token, event } = status { + if token >= add_to_machine::TOKEN_BASE { + self.add_to_machine + .update(cx, |dialog, cx| dialog.on_call(token, event, cx)); + return; + } + if token >= crate::settings_source_control::TOKEN_BASE { + self.settings.update(cx, |settings, cx| { + settings.on_source_control_call(token, event, cx) + }); + return; + } if token >= crate::settings_devices::TOKEN_BASE { self.settings.update(cx, |settings, cx| { settings.on_devices_call(token, event, cx) @@ -3702,7 +3744,7 @@ impl MainView { ) } - /// The web's `ComposerSurface.ContextStrip` under the composer; none for the scratch project. + /// The web's `ComposerSurface.ContextStrip` under the composer. The no-project draft keeps only the run-on and permissions controls. fn context_strip( &self, project: &ProjectEntry, @@ -3711,9 +3753,6 @@ impl MainView { let Status::Connected { label, machine, .. } = &self.status else { return None; }; - if project.scratch { - return None; - } let defaults = self.providers.as_ref().map_or_else( || Defaults::resolve(None, None, None), |c| c.defaults(Some(&project.id)), @@ -3725,8 +3764,10 @@ impl MainView { let is_repo = current.is_some(); let shown = strip::shown_branch(workspace, current, self.draft_bases.get(&project.id)); let branch_label = shown.map(|branch| branch.label(workspace, defaults.start_from_origin)); - self.branch_picker - .update(cx, |picker, _| picker.sync(&project.root, workspace)); + if !project.scratch { + self.branch_picker + .update(cx, |picker, _| picker.sync(&project.root, workspace)); + } // A reused worktree fixes the ref, so the control names its branch and does not open. let branch = if !is_repo { None @@ -3750,7 +3791,15 @@ impl MainView { WORKSPACE_SHORTCUT, ) }); - let host_control = if run_on_menu::is_select(&project.remote) { + let add_targets = self.add_targets(project); + // `useLogicalProjectEnvironments` with `scratchDraft`: every connected environment that + // has its own scratch project, instead of those that share the project. + let others = if project.scratch { + self.scratch_environments() + } else { + project.remote.clone() + }; + let host_control = if run_on_menu::is_select(&others, !add_targets.is_empty()) { run_on_menu::popover( run_on_menu::Menu { view: cx.entity(), @@ -3758,7 +3807,8 @@ impl MainView { label: environment.clone(), machine: *machine, }, - others: project.remote.clone(), + others, + can_add: !add_targets.is_empty(), highlight: self.host_highlight, }, self.host_open, @@ -3784,7 +3834,8 @@ impl MainView { .children(branch) .child(permissions_popover( &self.permissions, - self.permissions_open, + // One flag serves both triggers: the model picker's footer owns it while open. + self.permissions_open && !self.model_open, runtime_mode, PermissionsTrigger { mode: runtime_mode, @@ -3879,6 +3930,74 @@ impl MainView { cx.notify(); } + /// The other saved environments a no-project draft lists under "Run on". + fn scratch_environments(&self) -> Vec { + let mut others: Vec<_> = self + .environments + .iter() + .filter(|environment| { + environment.connected && environment.projects.iter().any(|entry| entry.scratch) + }) + .map(|environment| projects::RemoteMember { + label: environment.label.clone(), + machine: environment.machine, + }) + .collect(); + others.sort_by_key(|member| member.label.to_lowercase()); + others + } + + /// `useAddProjectTargets`: connected machines without the project that can be reached from + /// here, for a project that has a git remote. + fn add_targets(&self, project: &ProjectEntry) -> Vec { + if project.repository.is_none() { + return Vec::new(); + } + let mut targets: Vec<_> = self + .environments + .iter() + .filter(|environment| { + environment.connected + && crate::environments::sender(&environment.id).is_some() + && !environment.projects.iter().any(|other| { + project.logical_key.is_some() && other.logical_key == project.logical_key + }) + }) + .map(|environment| add_to_machine::Target { + id: environment.id.clone(), + label: environment.label.clone(), + machine: environment.machine, + }) + .collect(); + targets.sort_by_key(|target| target.label.to_lowercase()); + targets + } + + /// "Add to another machine": closes the menu and opens the clone dialog. + pub(crate) fn open_add_to_machine(&mut self, window: &mut Window, cx: &mut Context) { + self.set_host_open(false, window, cx); + let Status::Connected { label, machine, .. } = &self.status else { + return; + }; + let Some(project) = self.draft_project().filter(|entry| !entry.scratch) else { + return; + }; + let Some(repository) = project.repository.clone() else { + return; + }; + let targets = self.add_targets(project); + let request = add_to_machine::Request { + title: project.title.clone(), + root: project.root.clone(), + repository, + source_label: strip::environment_label(label), + source_machine: *machine, + targets, + }; + self.add_to_machine + .update(cx, |dialog, cx| dialog.open(request, window, cx)); + } + pub(crate) fn highlight_host(&mut self, index: usize, cx: &mut Context) { if self.host_highlight != index { self.host_highlight = index; @@ -4156,10 +4275,27 @@ impl MainView { } pub(crate) fn picker_toggle_older(&mut self, cx: &mut Context) { - self.model_ui.expanded = !self.model_ui.expanded; + self.flip_older(cx); cx.notify(); } + fn flip_older(&mut self, cx: &App) { + self.model_ui.expanded = !self.model_ui.expanded; + let Some((instances, _)) = self.picker_context() else { + return; + }; + let tab = self + .model_ui + .tab + .clone() + .unwrap_or(model_picker::Tab::Favorites); + let query = self.model_search.read(cx).value(); + self.model_ui.highlight = + model_picker::listing(&instances, &tab, &query, self.model_ui.expanded) + .rows + .len(); + } + /// Writes one option of the model in view into the composer's pick, so the next turn sends it. pub(crate) fn picker_set_option( &mut self, @@ -4268,7 +4404,7 @@ impl MainView { self.pick_model(&instance, &slug, cx); } } else if listing.older.is_some() { - self.model_ui.expanded = !self.model_ui.expanded; + self.flip_older(cx); } } (Focus::List, "left") if cursor == 0 => self.model_ui.focus = Focus::Sidebar, @@ -4331,7 +4467,7 @@ impl MainView { focus: self.model_ui.focus, effort: self.model_ui.effort, permissions: self.permissions.clone(), - permissions_open: self.permissions_open, + permissions_open: self.permissions_open && self.model_open, mode, instances, trigger: trigger.clone(), @@ -4342,6 +4478,9 @@ impl MainView { .open(self.model_open) .track_focus(&search.focus_handle(cx)) .p_0() + .bg(cx.theme().popover) + .border_1() + .border_color(dropdown_glass_edge(cx)) .on_open_change(move |open, window, cx| { search.update(cx, |state, cx| state.set_value("", window, cx)); view.update(cx, |this, cx| { @@ -6427,11 +6566,22 @@ impl Render for MainView { root.child(self.sidebar_drawer(overlay_width(width), window, cx)) }) .child(self.usage.clone()) + .child(self.add_to_machine.clone()) .child(self.settings.clone()) .child(self.toasts.clone()) } } +/// `dropdown-glass`'s `1px solid` edge: `--contrast-foreground` at 10%. GPUI has no backdrop blur, +/// so the fill takes the web's no-blur fallback, a solid `--popover` (index.css `@supports not`). +fn dropdown_glass_edge(cx: &App) -> Hsla { + if cx.theme().is_dark() { + gpui::white().opacity(0.1) + } else { + gpui::black().opacity(0.1) + } +} + /// The web's `--chat-composer-outline`: white at 5% in dark, black at 8% in light. fn composer_outline(dark: bool) -> Hsla { if dark { @@ -6495,6 +6645,7 @@ mod tests { root: String::new(), logical_key: None, remote: Vec::new(), + repository: None, } } diff --git a/apps/desktop-gpui/src/ui/add_to_machine.rs b/apps/desktop-gpui/src/ui/add_to_machine.rs new file mode 100644 index 000000000000..1cd20b139134 --- /dev/null +++ b/apps/desktop-gpui/src/ui/add_to_machine.rs @@ -0,0 +1,1363 @@ +//! The web's `AddProjectToMachineDialog` and `addProjectToMachine`: clone the open project onto +//! another connected machine, then bring the chosen ignored files across. +//! +//! The source's calls (`server.getConfig`, `projects.listIgnoredFiles`, `projects.readFile`) go +//! through the window's own connection. The target's (`server.getConfig`, `projectClone.start`, +//! the clone stream, `projects.writeFile`) go through the saved-environment connection +//! `environments::sender` hands out. Answers of both come back as `Status::Call` under this +//! dialog's token range. The window attaches to one environment, so the web's last step, landing +//! on the cloned project, has no equivalent: the dialog closes and a toast says where it went. + +use super::composer_menu::{self, OnClick, OnHover, Row, RowState}; +use super::settings::tokens::Tokens; +use super::*; +use crate::clone_flow::{self as flow, IgnoredFile, IgnoredFiles, Protocol, ServerSettings}; +use crate::environments; +use crate::projects::RepositoryInfo; +use crate::status::CallEvent; +use crate::usage_data; +use gpui::{EventEmitter, FocusHandle, KeyBinding, SharedString}; +use gpui_component::{Disableable as _, checkbox::Checkbox}; +use serde_json::Value; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Call tokens, above every other range. +pub const TOKEN_BASE: u64 = 1 << 47; +const CONTEXT: &str = "AddToMachine"; +/// `sm:max-w-xl`. +const WIDTH: f32 = 576.; +/// The middle truncation budget of a path in the source or target column, in characters. +const PATH_BUDGET: usize = 26; + +actions!(add_to_machine, [CloseAddToMachine]); + +pub fn bindings() -> Vec { + vec![KeyBinding::new("escape", CloseAddToMachine, Some(CONTEXT))] +} + +/// A connected machine without the project (`AddProjectTarget`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Target { + pub id: String, + pub label: String, + pub machine: MachineKind, +} + +/// What the dialog needs of the project it adds. +#[derive(Clone, Debug)] +pub struct Request { + pub title: String, + pub root: String, + pub repository: RepositoryInfo, + pub source_label: String, + pub source_machine: MachineKind, + pub targets: Vec, +} + +/// A toast for the window to show: a failure, or where the clone went. +pub struct Notice { + pub title: String, + pub description: Option, + pub error: bool, +} + +impl EventEmitter for AddToMachine {} + +enum Listing { + Waiting, + Loaded(IgnoredFiles), + Failed(String), +} + +struct Form { + request: Request, + target: usize, + protocol: Option, + path_override: Option, + selected: Option>, + /// `environment.capabilities.ignoredFileListing` of the source, once known. + can_list: Option, + listing: Listing, + configs: HashMap, + target_open: bool, + error: Option, + submitting: bool, + session: u64, +} + +impl Form { + fn target(&self) -> Option<&Target> { + let targets = &self.request.targets; + targets.get(self.target).or_else(|| targets.first()) + } + + fn source_url(&self) -> &str { + &self.request.repository.remote_url + } + + fn ssh_url(&self) -> Option { + flow::url_for_protocol(self.source_url(), Protocol::Ssh) + } + + fn https_url(&self) -> Option { + flow::url_for_protocol(self.source_url(), Protocol::Https) + } + + fn remote_url(&self) -> String { + match self.protocol { + Some(Protocol::Ssh) => self.ssh_url(), + Some(Protocol::Https) => self.https_url(), + None => None, + } + .unwrap_or_else(|| self.source_url().to_owned()) + } + + fn default_path(&self) -> String { + let settings = self + .target() + .and_then(|target| self.configs.get(&target.id)) + .cloned() + .unwrap_or_default(); + flow::destination_path( + &flow::initial_query(settings.base_directory.as_deref()), + &flow::directory_path(self.source_url(), settings.ghq_layout), + ) + } + + fn destination(&self) -> String { + self.path_override + .clone() + .unwrap_or_else(|| self.default_path()) + } + + fn files(&self) -> &[IgnoredFile] { + match &self.listing { + Listing::Loaded(listed) => &listed.files, + _ => &[], + } + } + + fn chosen(&self) -> HashSet { + self.selected + .clone() + .unwrap_or_else(|| flow::default_selection(self.files())) + } + + /// Submitting before the file list loads would silently skip the default `.env` copies. + fn unavailable(&self) -> bool { + let listed = match self.can_list { + None => false, + Some(false) => true, + Some(true) => !matches!(self.listing, Listing::Waiting), + }; + self.target().is_none() || !listed + } +} + +enum Pending { + SourceConfig { session: u64 }, + Listing { session: u64 }, + TargetConfig { session: u64, id: String }, + Read { path: String }, + Start, + Watch { job: u64 }, + Write { job: u64, path: String }, +} + +/// A submit in flight: the reads of the files, then the clone. +struct Submit { + session: u64, + project_id: String, + target_id: String, + target_label: String, + title: String, + remote_url: String, + destination: String, + subdirectory: String, + files: Vec, + reads: HashMap>, +} + +/// What is left after the clone starts: wait for it, then write the files. +struct Job { + project_id: String, + target_id: String, + target_label: String, + cwd: String, + subdirectory: String, + seen: bool, + reads: Vec<(String, Result)>, + failed: Vec, +} + +pub struct AddToMachine { + actions: ActionSender, + focus: FocusHandle, + window: gpui::AnyWindowHandle, + previous_focus: Option, + folder: Entity, + scroll: gpui::ScrollHandle, + form: Option
, + session: u64, + next_token: u64, + next_job: u64, + pending: HashMap, + submit: Option, + jobs: HashMap, +} + +impl AddToMachine { + pub fn new(actions: ActionSender, window: &mut Window, cx: &mut Context) -> Self { + let folder = cx.new(|cx| InputState::new(window, cx)); + cx.subscribe_in( + &folder, + window, + |this, _, event: &InputEvent, window, cx| match event { + InputEvent::Change => this.folder_changed(window, cx), + InputEvent::PressEnter { .. } => this.submit(cx), + _ => {} + }, + ) + .detach(); + Self { + actions, + focus: cx.focus_handle(), + window: window.window_handle(), + previous_focus: None, + folder, + scroll: gpui::ScrollHandle::new(), + form: None, + session: 0, + next_token: TOKEN_BASE, + next_job: 0, + pending: HashMap::new(), + submit: None, + jobs: HashMap::new(), + } + } + + pub fn open(&mut self, request: Request, window: &mut Window, cx: &mut Context) { + if request.targets.is_empty() { + return; + } + self.session += 1; + self.previous_focus = window.focused(cx); + let protocol = flow::url_protocol(&request.repository.remote_url); + let first = request.targets[0].id.clone(); + self.form = Some(Form { + request, + target: 0, + protocol, + path_override: None, + selected: None, + can_list: None, + listing: Listing::Waiting, + configs: HashMap::new(), + target_open: false, + error: None, + submitting: false, + session: self.session, + }); + let session = self.session; + self.call_primary( + Call::GetConfig, + serde_json::json!({}), + Pending::SourceConfig { session }, + cx, + ); + self.request_target_config(&first, cx); + self.set_folder(window, cx); + window.focus(&self.focus, cx); + cx.notify(); + } + + pub fn close(&mut self, window: &mut Window, cx: &mut Context) { + if self.form.take().is_none() { + return; + } + if let Some(previous) = self.previous_focus.take() { + window.focus(&previous, cx); + } + cx.notify(); + } + + fn send( + &mut self, + sender: Option, + call: Call, + payload: Value, + pending: Pending, + cx: &mut Context, + ) { + self.next_token += 1; + let token = self.next_token; + let sent = sender.is_some_and(|sender| { + sender + .try_send(Action::Call { + token, + call, + payload, + }) + .is_ok() + }); + if sent { + self.pending.insert(token, pending); + return; + } + let failure = CallEvent::Failed("Not connected to that machine.".to_owned()); + self.settled(pending, failure, cx); + } + + fn call_primary( + &mut self, + call: Call, + payload: Value, + pending: Pending, + cx: &mut Context, + ) { + let sender = Some(self.actions.clone()); + self.send(sender, call, payload, pending, cx); + } + + fn call_target( + &mut self, + id: &str, + call: Call, + payload: Value, + pending: Pending, + cx: &mut Context, + ) { + self.send(environments::sender(id), call, payload, pending, cx); + } + + fn request_target_config(&mut self, id: &str, cx: &mut Context) { + let session = self.session; + self.call_target( + id, + Call::GetConfig, + serde_json::json!({}), + Pending::TargetConfig { + session, + id: id.to_owned(), + }, + cx, + ); + } + + /// Puts the folder the form means into the input. + fn set_folder(&mut self, window: &mut Window, cx: &mut Context) { + let Some(form) = &self.form else { return }; + let text = form.destination(); + self.folder.update(cx, |input, cx| { + if input.value() != text { + input.set_value(text, window, cx); + } + }); + } + + /// The same, from where there is no window: the next frame sets it. + fn set_folder_later(&mut self, cx: &mut Context) { + let Some(form) = &self.form else { return }; + let text = form.destination(); + let (folder, window) = (self.folder.clone(), self.window); + cx.defer(move |cx| { + let _ = window.update(cx, |_, window, cx| { + folder.update(cx, |input, cx| { + if input.value() != text { + input.set_value(text, window, cx); + } + }); + }); + }); + } + + /// `setPathOverride(resolvePastedPath(destinationPath, value))`. + fn folder_changed(&mut self, window: &mut Window, cx: &mut Context) { + let Some(form) = self.form.as_mut() else { + return; + }; + let typed = self.folder.read(cx).value().to_string(); + let previous = form.destination(); + if typed == previous { + return; + } + let resolved = flow::resolve_pasted_path(&previous, &typed); + form.path_override = Some(resolved.clone()); + if resolved != typed { + self.folder + .update(cx, |input, cx| input.set_value(resolved, window, cx)); + } + cx.notify(); + } + + fn pick_target(&mut self, index: usize, window: &mut Window, cx: &mut Context) { + let Some(form) = self.form.as_mut() else { + return; + }; + form.target_open = false; + form.target = index; + let missing = form + .target() + .filter(|target| !form.configs.contains_key(&target.id)) + .map(|target| target.id.clone()); + if let Some(id) = missing { + self.request_target_config(&id, cx); + } + self.set_folder(window, cx); + cx.notify(); + } + + fn set_target_open(&mut self, open: bool, cx: &mut Context) { + if let Some(form) = self.form.as_mut() { + form.target_open = open; + cx.notify(); + } + } + + fn set_protocol(&mut self, protocol: Protocol, cx: &mut Context) { + if let Some(form) = self.form.as_mut() { + form.protocol = Some(protocol); + cx.notify(); + } + } + + fn toggle_file(&mut self, path: &str, cx: &mut Context) { + let Some(form) = self.form.as_mut() else { + return; + }; + let mut next = form.chosen(); + if !next.remove(path) { + next.insert(path.to_owned()); + } + form.selected = Some(next); + cx.notify(); + } + + fn toggle_all(&mut self, cx: &mut Context) { + let Some(form) = self.form.as_mut() else { + return; + }; + form.selected = Some(flow::toggled_all(&form.chosen(), form.files())); + cx.notify(); + } + + fn submit(&mut self, cx: &mut Context) { + let Some(form) = self.form.as_mut() else { + return; + }; + if form.submitting || form.unavailable() { + return; + } + let destination = form.destination().trim().to_owned(); + if destination.is_empty() { + form.error = Some("Choose a folder on the other machine.".to_owned()); + return cx.notify(); + } + let Some(target) = form.target().cloned() else { + return; + }; + form.submitting = true; + form.error = None; + let chosen = form.chosen(); + let files: Vec = form + .files() + .iter() + .filter(|file| chosen.contains(&file.path)) + .map(|file| file.path.clone()) + .collect(); + let request = &form.request; + let submit = Submit { + session: form.session, + project_id: new_project_id(), + target_id: target.id, + target_label: target.label, + title: request.title.clone(), + remote_url: form.remote_url(), + destination, + subdirectory: flow::project_subdirectory( + request.repository.root_path.as_deref(), + &request.root, + ), + files: files.clone(), + reads: HashMap::new(), + }; + let root = request.root.clone(); + self.submit = Some(submit); + cx.notify(); + if files.is_empty() { + return self.start_clone(cx); + } + // Read first, so the copy reflects the files as they were when the user asked. + for path in files { + self.call_primary( + Call::ReadFile, + flow::read_payload(&root, &path), + Pending::Read { path }, + cx, + ); + } + } + + fn start_clone(&mut self, cx: &mut Context) { + let Some(submit) = &self.submit else { return }; + let payload = flow::start_payload( + &submit.project_id, + &submit.title, + &usage_data::iso_from_ms(now_ms()), + &submit.remote_url, + &submit.destination, + ); + let target = submit.target_id.clone(); + self.call_target( + &target, + Call::StartProjectClone, + payload, + Pending::Start, + cx, + ); + } + + /// `setError` while the dialog is up, else a toast: the user has gone on. + fn fail_submit(&mut self, message: String, cx: &mut Context) { + let Some(submit) = self.submit.take() else { + return; + }; + match self + .form + .as_mut() + .filter(|form| form.session == submit.session) + { + Some(form) => { + form.error = Some(message); + form.submitting = false; + } + None => cx.emit(Notice { + title: "Could not start the clone".to_owned(), + description: Some(message), + error: true, + }), + } + cx.notify(); + } + + pub fn on_call(&mut self, token: u64, event: CallEvent, cx: &mut Context) { + if matches!(event, CallEvent::Progress(_)) { + let Some(Pending::Watch { job }) = self.pending.get(&token) else { + return; + }; + let job = *job; + return self.clones_changed(job, token, event, cx); + } + if let Some(pending) = self.pending.remove(&token) { + self.settled(pending, event, cx); + } + } + + fn settled(&mut self, pending: Pending, event: CallEvent, cx: &mut Context) { + match pending { + Pending::SourceConfig { session } => { + let current = self.form.as_mut().filter(|form| form.session == session); + let Some(form) = current else { return }; + let listing = match &event { + CallEvent::Done(value) => flow::decode_config(value).ignored_file_listing, + _ => false, + }; + form.can_list = Some(listing); + if listing { + let cwd = form.request.root.clone(); + self.call_primary( + Call::ListIgnoredFiles, + flow::list_payload(&cwd), + Pending::Listing { session }, + cx, + ); + } + } + Pending::Listing { session } => { + let current = self.form.as_mut().filter(|form| form.session == session); + let Some(form) = current else { return }; + form.listing = match event { + CallEvent::Done(value) => flow::decode_ignored_files(&value).map_or_else( + || Listing::Failed("The server sent an unreadable list.".to_owned()), + Listing::Loaded, + ), + CallEvent::Failed(message) => Listing::Failed(message), + CallEvent::Progress(_) => return, + }; + } + Pending::TargetConfig { session, id } => { + let current = self.form.as_mut().filter(|form| form.session == session); + let Some(form) = current else { return }; + let settings = match &event { + CallEvent::Done(value) => flow::decode_config(value), + _ => ServerSettings::default(), + }; + form.configs.insert(id, settings); + self.set_folder_later(cx); + } + Pending::Read { path } => { + let Some(submit) = self.submit.as_mut() else { + return; + }; + let read = match event { + CallEvent::Done(value) => flow::decode_read(&value), + CallEvent::Failed(message) => Err(message), + CallEvent::Progress(_) => return, + }; + submit.reads.insert(path, read); + if submit.reads.len() == submit.files.len() { + self.start_clone(cx); + } + } + Pending::Start => match event { + CallEvent::Done(value) => self.clone_started(&value, cx), + CallEvent::Failed(message) => self.fail_submit(message, cx), + CallEvent::Progress(_) => {} + }, + Pending::Watch { job } => { + // The stream ended before the clone did. + if self.jobs.remove(&job).is_some() { + let message = match event { + CallEvent::Failed(message) => message, + _ => "The clone list closed.".to_owned(), + }; + cx.emit(Notice { + title: "Lost track of the clone".to_owned(), + description: Some(message), + error: true, + }); + } + } + Pending::Write { job, path } => { + if let (CallEvent::Failed(message), Some(job)) = (event, self.jobs.get_mut(&job)) { + job.failed.push(format!("{path}: {message}")); + } + self.write_next(job, cx); + } + } + cx.notify(); + } + + /// `projectClone.start` answered: the dialog is done, and the files wait for the clone. + fn clone_started(&mut self, value: &Value, cx: &mut Context) { + let Some(cwd) = flow::decode_started(value) else { + return self.fail_submit("The server sent no folder for the clone.".to_owned(), cx); + }; + let Some(submit) = self.submit.take() else { + return; + }; + if self + .form + .as_ref() + .is_some_and(|form| form.session == submit.session) + { + self.form = None; + if let Some(previous) = self.previous_focus.take() { + let window = self.window; + cx.defer(move |cx| { + let _ = window.update(cx, |_, window, cx| window.focus(&previous, cx)); + }); + } + } + cx.emit(Notice { + title: format!("Cloning {} to {}", submit.title, submit.target_label), + description: None, + error: false, + }); + if submit.files.is_empty() { + return; + } + self.next_job += 1; + let id = self.next_job; + let mut reads = Vec::new(); + for path in &submit.files { + if let Some(read) = submit.reads.get(path) { + reads.push((path.clone(), read.clone())); + } + } + let target = submit.target_id.clone(); + self.jobs.insert( + id, + Job { + project_id: submit.project_id, + target_id: submit.target_id, + target_label: submit.target_label, + cwd, + subdirectory: submit.subdirectory, + seen: false, + reads, + failed: Vec::new(), + }, + ); + self.call_target( + &target, + Call::WatchProjectClones, + serde_json::json!({}), + Pending::Watch { job: id }, + cx, + ); + } + + /// `waitForClone`: done once the clone is, gone once it was seen and then vanished. + fn clones_changed(&mut self, id: u64, token: u64, event: CallEvent, cx: &mut Context) { + let CallEvent::Progress(list) = event else { + return; + }; + let Some(job) = self.jobs.get_mut(&id) else { + return; + }; + let wait = flow::clone_wait(&list, &job.project_id, &mut job.seen); + if wait == flow::CloneWait::Pending { + return; + } + self.pending.remove(&token); + let target = job.target_id.clone(); + if let Some(sender) = environments::sender(&target) { + let _ = sender.try_send(Action::EndCall { token }); + } + match wait { + flow::CloneWait::Done => self.write_next(id, cx), + _ => { + self.jobs.remove(&id); + } + } + } + + /// Writes the next file, or reports what failed once none is left. + fn write_next(&mut self, id: u64, cx: &mut Context) { + let Some(job) = self.jobs.get_mut(&id) else { + return; + }; + while !job.reads.is_empty() { + let (path, read) = job.reads.remove(0); + match read { + Err(message) => job.failed.push(format!("{path}: {message}")), + Ok(contents) => { + let destination = flow::copy_target(&job.subdirectory, &path); + let payload = flow::write_payload(&job.cwd, &destination, &contents); + let target = job.target_id.clone(); + return self.call_target( + &target, + Call::WriteFile, + payload, + Pending::Write { job: id, path }, + cx, + ); + } + } + } + if let Some(job) = self.jobs.remove(&id) + && !job.failed.is_empty() + { + cx.emit(Notice { + title: flow::failure_title(job.failed.len(), &job.target_label), + description: Some(job.failed.join("\n")), + error: true, + }); + } + } +} + +fn now_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_millis() as i64) +} + +/// `Button` at the default size: `h-8 px-3 text-sm`, in the outline or primary variant. +fn button(id: &'static str, primary: bool, enabled: bool, t: &Tokens) -> gpui::Stateful { + let (accent, input, primary_color) = (t.accent, t.input, t.primary); + div() + .id(id) + .flex() + .items_center() + .justify_center() + .h(px(32.)) + .px(px(12.)) + .rounded(px(8.)) + .border_1() + .text_size(px(14.)) + .line_height(px(20.)) + .font_weight(FontWeight::MEDIUM) + .when(primary, |button| { + button + .border_color(primary_color) + .bg(primary_color) + .text_color(gpui::white()) + }) + .when(!primary, |button| { + button + .border_color(input) + .text_color(t.foreground) + .bg(if t.dark { + t.input.opacity(0.32) + } else { + t.popover + }) + }) + .when(enabled, |button| { + button.cursor_pointer().hover(move |style| { + if primary { + style.opacity(0.9) + } else { + style.bg(accent.opacity(0.5)) + } + }) + }) + .when(!enabled, |button| button.opacity(0.64)) +} + +fn label(text: impl Into, t: &Tokens) -> gpui::Div { + div() + .text_size(px(14.)) + .line_height(px(16.)) + .font_weight(FontWeight::MEDIUM) + .text_color(t.foreground) + .child(text.into()) +} + +fn note(text: impl Into, t: &Tokens) -> gpui::Div { + div() + .text_xs() + .line_height(px(16.)) + .text_color(t.muted_foreground) + .child(text.into()) +} + +/// The target select's trigger: `SelectTrigger size="sm"`. +#[derive(IntoElement)] +struct SelectTrigger { + icon: Icons, + label: SharedString, + open: bool, +} + +impl Selectable for SelectTrigger { + fn selected(self, _: bool) -> Self { + self + } + + fn is_selected(&self) -> bool { + false + } + + fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + fn is_open(&self) -> bool { + self.open + } +} + +impl RenderOnce for SelectTrigger { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let t = Tokens::of(cx); + h_flex() + .id("add-machine-target") + .w_full() + .min_w_0() + .h(px(32.)) + .px(px(10.)) + .gap(px(6.)) + .items_center() + .rounded(px(8.)) + .border_1() + .border_color(t.input) + .bg(if t.dark { + t.input.opacity(0.32) + } else { + t.popover + }) + .text_size(px(14.)) + .text_color(t.foreground) + .cursor_pointer() + .when(self.open, |trigger| trigger.border_color(t.ring)) + .child(Icon::new(self.icon).size(px(14.))) + .child(div().flex_1().min_w_0().truncate().child(self.label)) + .child( + div() + .text_color(t.muted_foreground) + .child(Icon::new(IconName::ChevronDown).size(px(14.))), + ) + } +} + +impl Render for AddToMachine { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(form) = &self.form else { + return gpui::Empty.into_any_element(); + }; + let t = Tokens::of(cx); + let mono = cx.theme().mono_font_family.clone(); + let viewport = window.viewport_size(); + let width = (f32::from(viewport.width) - 32.).min(WIDTH); + let column = ((width - 48. - 112. - 24.) / 2.).max(80.); + let target = form.target().cloned(); + let target_label = target.as_ref().map(|target| target.label.clone()); + let destination = form.destination(); + let chosen = form.chosen(); + let selected_count = chosen.len(); + let entity = cx.entity(); + + let machine = |icon: Icons, name: String| { + h_flex() + .min_w_0() + .gap(px(6.)) + .items_center() + .text_size(px(14.)) + .line_height(px(20.)) + .font_weight(FontWeight::MEDIUM) + .child(div().flex_shrink_0().child(Icon::new(icon).size(px(14.)))) + .child(div().min_w_0().truncate().child(name)) + }; + let path_line = |path: &str| { + div() + .min_w_0() + .font_family(mono.clone()) + .text_xs() + .line_height(px(16.)) + .text_color(t.muted_foreground) + .child(super::middle_truncate(path, PATH_BUDGET)) + }; + + let source = v_flex() + .w(px(column)) + .gap_1() + .child(machine( + machine_icon(form.request.source_machine), + form.request.source_label.clone(), + )) + .child(path_line(&form.request.root)); + let arrow = v_flex() + .w(px(112.)) + .items_center() + .gap(px(2.)) + .text_xs() + .line_height(px(16.)) + .text_color(t.muted_foreground) + .child("git clone") + .child( + h_flex() + .w_full() + .items_center() + .child(div().h(px(1.)).flex_1().bg(t.border)) + .child( + div() + .ml(px(-4.)) + .child(Icon::new(IconName::ArrowRight).size(px(14.))), + ), + ) + .child(flow::file_count_label(selected_count)); + let target_control = if form.request.targets.len() > 1 { + self.target_select(form, column, entity.clone()) + } else { + machine( + target + .as_ref() + .map_or(Icons::Server, |target| machine_icon(target.machine)), + target_label + .clone() + .unwrap_or_else(|| "No machine available".to_owned()), + ) + .into_any_element() + }; + let route = h_flex() + .items_center() + .gap_3() + .child(source) + .child(arrow) + .child( + v_flex() + .w(px(column)) + .gap_1() + .child(target_control) + .child(path_line(&destination)), + ); + + let protocol = (form.ssh_url().is_some() && form.https_url().is_some()).then(|| { + let segment = |id: &'static str, text: &'static str, which: Protocol| { + let on = form.protocol == Some(which); + let view = entity.clone(); + div() + .id(id) + .h(px(30.)) + .px(px(12.)) + .flex() + .items_center() + .text_size(px(14.)) + .font_weight(FontWeight::MEDIUM) + .cursor_pointer() + .text_color(if on { + t.accent_foreground + } else { + t.muted_foreground + }) + .when(on, |segment| segment.bg(t.accent)) + .hover(move |style| style.bg(t.accent)) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| this.set_protocol(which, cx)) + }) + .child(text) + }; + h_flex() + .self_start() + .overflow_hidden() + .rounded(px(8.)) + .border_1() + .border_color(t.input) + .child(segment("add-machine-ssh", "SSH", Protocol::Ssh)) + .child(div().w(px(1.)).bg(t.input)) + .child(segment("add-machine-https", "HTTPS", Protocol::Https)) + }); + let clone_over = v_flex() + .gap(px(6.)) + .child(label("Clone over", &t)) + .children(protocol) + .child( + div() + .truncate() + .font_family(mono.clone()) + .text_xs() + .line_height(px(16.)) + .text_color(t.muted_foreground) + .child(form.remote_url()), + ); + let folder = v_flex() + .gap(px(6.)) + .child(label( + format!( + "Folder on {}", + target_label.as_deref().unwrap_or("the other machine") + ), + &t, + )) + .child(Input::new(&self.folder)); + let ignored = (form.can_list == Some(true)) + .then(|| self.ignored_section(form, &chosen, &t, &mono, entity.clone())); + let error = form.error.clone().map(|message| { + div() + .text_size(px(14.)) + .line_height(px(20.)) + .text_color(t.destructive) + .child(message) + }); + + let unavailable = form.unavailable(); + let submitting = form.submitting; + let cancel = button("add-machine-cancel", false, true, &t) + .on_click(cx.listener(|this, _, window, cx| this.close(window, cx))) + .child("Cancel"); + let enabled = !unavailable && !submitting; + let clone = button("add-machine-submit", true, enabled, &t) + .when(enabled, |clone| { + clone.on_click(cx.listener(|this, _, _, cx| this.submit(cx))) + }) + .child(flow::submit_label(submitting, target_label.as_deref())); + + let title = form + .request + .repository + .display_name + .clone() + .unwrap_or_else(|| form.request.title.clone()); + let header = v_flex() + .gap_2() + .px_6() + .pt_6() + .pb_3() + .child( + div() + .text_size(px(20.)) + .line_height(px(27.5)) + .font_weight(FontWeight::SEMIBOLD) + .child("Add to another machine"), + ) + .child( + div() + .text_size(px(14.)) + .line_height(px(20.)) + .text_color(t.muted_foreground) + .child(format!( + "Clone {title} and bring the local files git does not track." + )), + ); + let panel = v_flex() + .id("add-machine-panel") + .track_scroll(&self.scroll) + .overflow_y_scroll() + .min_h_0() + .gap_4() + .px_6() + .pt_1() + .pb_6() + .child(route) + .child(clone_over) + .child(folder) + .children(ignored) + .children(error); + let footer = h_flex() + .justify_end() + .gap_2() + .px_6() + .py_4() + .border_t_1() + .border_color(t.border) + .bg(t.muted) + .child(cancel) + .child(clone); + let close = div() + .id("add-machine-close") + .absolute() + .right(px(8.)) + .top(px(8.)) + .size(px(28.)) + .flex() + .items_center() + .justify_center() + .rounded(px(8.)) + .cursor_pointer() + .text_color(t.muted_foreground) + .hover(move |style| style.bg(t.accent)) + .on_click(cx.listener(|this, _, window, cx| this.close(window, cx))) + .child(Icon::new(IconName::Close).size(px(14.))); + + div() + .absolute() + .inset_0() + .key_context(CONTEXT) + .track_focus(&self.focus) + .on_action( + cx.listener(|this, _: &CloseAddToMachine, window, cx| this.close(window, cx)), + ) + .child( + div() + .id("add-machine-backdrop") + .absolute() + .inset_0() + .occlude() + .bg(t.backdrop()) + .on_click(cx.listener(|this, _, window, cx| this.close(window, cx))), + ) + .child( + div() + .absolute() + .inset_0() + .flex() + .items_center() + .justify_center() + .child( + v_flex() + .relative() + .occlude() + .w(px(width)) + .max_h(viewport.height - px(32.)) + .overflow_hidden() + .rounded(px(16.)) + .border_1() + .border_color(t.border) + .bg(t.popover) + .text_color(t.foreground) + .shadow_lg() + .child(header) + .child(panel) + .child(footer) + .child(close), + ), + ) + .into_any_element() + } +} + +impl AddToMachine { + /// `Select size="sm"` over the targets, in the shared composer popup. + fn target_select(&self, form: &Form, column: f32, entity: Entity) -> AnyElement { + let current = form.target().cloned(); + let targets = form.request.targets.clone(); + let selected = current.as_ref().map(|target| target.id.clone()); + let open_view = entity.clone(); + let width = px(column); + Popover::new("add-machine-select") + .anchor(Anchor::TopLeft) + .offset(px(4.)) + .open(form.target_open) + .p_0() + .appearance(false) + .on_open_change(move |open, _, cx| { + open_view.update(cx, |this, cx| this.set_target_open(*open, cx)); + }) + .trigger(SelectTrigger { + icon: current + .as_ref() + .map_or(Icons::Server, |target| machine_icon(target.machine)), + label: current + .as_ref() + .map_or_else(SharedString::default, |target| target.label.clone().into()), + open: form.target_open, + }) + .content(move |_, _, cx| { + let rows: Vec = targets + .iter() + .enumerate() + .map(|(index, target)| { + let pick = entity.clone(); + Row { + id: ("add-machine-row", index), + state: RowState { + selected: selected.as_deref() == Some(target.id.as_str()), + ..RowState::default() + }, + body: h_flex() + .gap(px(6.)) + .items_center() + .child(Icon::new(machine_icon(target.machine)).size(px(12.))) + .child(target.label.clone()) + .into_any_element(), + on_hover: Box::new(|_: &mut App| {}) as OnHover, + on_click: Box::new(move |window: &mut Window, cx: &mut App| { + pick.update(cx, |this, cx| this.pick_target(index, window, cx)); + }) as OnClick, + } + }) + .collect(); + composer_menu::popup(None, Some(width), rows, cx) + }) + .into_any_element() + } + + /// "Copy ignored files": the header, the list and the footnote. + fn ignored_section( + &self, + form: &Form, + chosen: &HashSet, + t: &Tokens, + mono: &SharedString, + entity: Entity, + ) -> gpui::Div { + let files = form.files(); + let copyable = files.iter().any(IgnoredFile::copyable); + let bytes: u64 = files + .iter() + .filter(|file| chosen.contains(&file.path)) + .map(|file| file.byte_length) + .sum(); + let count = format!( + "{} of {}{}", + chosen.len(), + files.len(), + if bytes > 0 { + format!(" \u{b7} {}", flow::format_bytes(bytes)) + } else { + String::new() + } + ); + let all = flow::all_selected(chosen, files); + let toggle_view = entity.clone(); + let accent = t.accent; + let header = h_flex() + .items_center() + .gap_2() + .child(div().flex_1().child(label("Copy ignored files", t))) + .when(copyable, |header| { + header.child(note(count, t)).child( + div() + .id("add-machine-all") + .flex() + .items_center() + .h(px(24.)) + .px(px(7.)) + .rounded(px(8.)) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .cursor_pointer() + .hover(move |style| style.bg(accent)) + .on_click(move |_, _, cx| { + toggle_view.update(cx, |this, cx| this.toggle_all(cx)) + }) + .child(if all { "None" } else { "All" }), + ) + }); + let body = match &form.listing { + Listing::Failed(message) => Some( + div() + .text_xs() + .line_height(px(16.)) + .text_color(t.destructive) + .child(message.clone()), + ), + Listing::Waiting => Some(note("Finding ignored files\u{2026}", t)), + Listing::Loaded(listed) if listed.files.is_empty() => { + Some(note("No ignored files to copy.", t)) + } + Listing::Loaded(_) => None, + }; + let rows = (!files.is_empty()).then(|| { + let last = files.len() - 1; + v_flex() + .id("add-machine-files") + .max_h(px(208.)) + .overflow_y_scroll() + .rounded(px(6.)) + .border_1() + .border_color(t.border) + .children(files.iter().enumerate().map(|(index, file)| { + let too_large = !file.copyable(); + let path = file.path.clone(); + let view = entity.clone(); + h_flex() + .id(("add-machine-file", index)) + .items_center() + .gap_2() + .px(px(10.)) + .py(px(6.)) + .text_size(px(14.)) + .when(index != last, |row| row.border_b_1().border_color(t.border)) + .when(too_large, |row| row.opacity(0.64)) + .when(!too_large, |row| { + row.cursor_pointer().on_click(move |_, _, cx| { + view.update(cx, |this, cx| this.toggle_file(&path, cx)) + }) + }) + .child( + Checkbox::new(("add-machine-check", index)) + .checked(chosen.contains(&file.path)) + .disabled(too_large), + ) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .font_family(mono.clone()) + .text_xs() + .child(file.path.clone()), + ) + .child( + div() + .flex_shrink_0() + .text_xs() + .text_color(t.muted_foreground) + .child(flow::size_label(file)), + ) + })) + }); + let truncated = match &form.listing { + Listing::Loaded(listed) if listed.truncated => Some(note( + format!("Showing the first {} files.", listed.files.len()), + t, + )), + _ => None, + }; + v_flex() + .gap(px(6.)) + .child(header) + .children(body) + .children(rows) + .children(truncated) + .child(note( + "Files are copied after the clone finishes. Folders git ignores whole, like node_modules, are left out.", + t, + )) + } +} diff --git a/apps/desktop-gpui/src/ui/composer_menu.rs b/apps/desktop-gpui/src/ui/composer_menu.rs index b1bd31992cb8..80ff46f2cb1b 100644 --- a/apps/desktop-gpui/src/ui/composer_menu.rs +++ b/apps/desktop-gpui/src/ui/composer_menu.rs @@ -141,9 +141,9 @@ pub(super) fn plain_body(icon: Icons, label: impl IntoElement, cx: &App) -> gpui } /// The popup: `dropdown-glass` painted opaque on a `rounded-lg` shell, a `p-1` list, the group -/// label, then the rows. `width` is a fixed width; otherwise `min-w-[150px]`. +/// label (when there is one), then the rows. `width` is a fixed width; otherwise `min-w-[150px]`. pub(super) fn popup( - group: &'static str, + group: Option<&'static str>, width: Option, rows: Vec, cx: &App, @@ -208,16 +208,18 @@ pub(super) fn popup( .child( v_flex() .p_1() - .child( - div() - .px_2() - .py(px(6.)) - .text_xs() - .line_height(px(16.)) - .font_weight(FontWeight::MEDIUM) - .text_color(tokens.muted_foreground) - .child(group), - ) + .when_some(group, |list, group| { + list.child( + div() + .px_2() + .py(px(6.)) + .text_xs() + .line_height(px(16.)) + .font_weight(FontWeight::MEDIUM) + .text_color(tokens.muted_foreground) + .child(group), + ) + }) .children(rows), ) } diff --git a/apps/desktop-gpui/src/ui/model_popover.rs b/apps/desktop-gpui/src/ui/model_popover.rs index ad94936fb261..227c1c213fcd 100644 --- a/apps/desktop-gpui/src/ui/model_popover.rs +++ b/apps/desktop-gpui/src/ui/model_popover.rs @@ -3,7 +3,7 @@ use super::*; use crate::model_picker::{Focus, Listing, Tab}; -use crate::providers::{Control, PickerInstance}; +use crate::providers::{Control, Mark, PickerInstance}; /// `h-86.5`: the body, rail and columns, above the footer. const BODY_HEIGHT: f32 = 346.; @@ -44,6 +44,11 @@ struct Palette { accent: Hsla, track: Hsla, selected: Hsla, + ring: Hsla, + kbd: Hsla, + update: Hsla, + update_text: Hsla, + radius: Pixels, } impl Palette { @@ -60,6 +65,15 @@ impl Palette { accent: theme.accent, track: theme.input.opacity(0.4), selected: theme.input.opacity(0.72), + ring: theme.ring, + kbd: theme.foreground.opacity(0.07), + update: theme.primary, + update_text: if theme.is_dark() { + gpui::rgb(0x60a5fa).into() + } else { + theme.primary + }, + radius: theme.radius - px(1.), } } } @@ -79,8 +93,17 @@ impl Panel { .child( h_flex() .h(px(BODY_HEIGHT)) + .min_h_0() + .overflow_hidden() .child(self.rail(searching, palette, cx)) - .child(self.models(&listing, searching, search_focused, palette, cx)) + .child(self.models( + &listing, + searching, + search_focused, + !effort_column, + palette, + cx, + )) .children(effort_column.then(|| self.effort_column(palette))), ) .child(self.footer(palette, cx)) @@ -99,6 +122,7 @@ impl Panel { self.tab == Tab::Favorites, "Favorites".to_owned(), true, + None, Tab::Favorites, palette, Icon::new(if has_favorites { @@ -126,23 +150,37 @@ impl Panel { self.tab == tab, note, instance.unavailable.is_none(), + Some(instance.mark.clone()), tab, palette, provider_mark_at(&instance.mark, 24., 20., 1., cx).into_any_element(), ) }); - v_flex() - .id("model-rail") + div() .w(px(RAIL_WIDTH)) .h_full() + .min_h_0() .flex_shrink_0() - .gap_1() - .p_1() + .overflow_hidden() .bg(cx.theme().muted.opacity(0.3)) - .overflow_y_scroll() - .child(favorites) - .child(div().h_px().w_full().flex_shrink_0().bg(palette.border)) - .children(items) + .rounded_tl(palette.radius) + .child( + v_flex() + .id("model-rail") + .size_full() + .min_h_0() + .overflow_y_scroll() + .child( + v_flex() + .flex_shrink_0() + .min_h_full() + .gap_1() + .p_1() + .child(favorites) + .child(div().h_px().w_full().flex_shrink_0().bg(palette.border)) + .children(items), + ), + ) } #[allow(clippy::too_many_arguments)] @@ -153,6 +191,7 @@ impl Panel { active: bool, note: String, usable: bool, + mark: Option, tab: Tab, palette: Palette, icon: AnyElement, @@ -175,7 +214,7 @@ impl Panel { .hover(move |style| style.bg(palette.foreground.opacity(0.1))) }) .when(!usable, |item| item.opacity(0.5).cursor_not_allowed()) - .tooltip(tooltip(note)) + .tooltip(rail_tooltip(note, mark)) .child(icon) .when(current, |item| { // `h-5 w-0.75` at `right-1`, centered on the item. @@ -205,6 +244,7 @@ impl Panel { listing: &Listing, searching: bool, search_focused: bool, + last: bool, palette: Palette, cx: &App, ) -> impl IntoElement { @@ -218,7 +258,6 @@ impl Panel { row.model, jump, on_list && self.highlight == index, - searching || self.tab == Tab::Favorites, palette, cx, ) @@ -229,19 +268,20 @@ impl Panel { div() .id("model-older") .flex() - .h(px(ROW_HEIGHT)) + .h(px(28.)) .w_full() .flex_shrink_0() .items_center() .px_2() .rounded_sm() .text_sm() + .line_height(px(19.25)) .text_color(palette.muted) .cursor_pointer() .when(highlighted, |row| { row.bg(palette.hover).text_color(palette.foreground) }) - .hover(move |style| style.text_color(palette.foreground)) + .hover(move |style| style.bg(palette.hover).text_color(palette.foreground)) .on_click(move |_, _, cx| { view.update(cx, |this, cx| this.picker_toggle_older(cx)); }) @@ -267,16 +307,19 @@ impl Panel { .min_w_0() .h_full() .bg(palette.fill) + .when(last, |column| column.rounded_tr(palette.radius)) .border_l_1() .border_color(palette.border) .child( - // The search sits in a `px-3 pt-2.5` strip, underlined in the primary tone on focus. + // The search sits in a `px-3 pt-2.5` strip, underlined in the ring tone on focus. div().px_3().pt(px(10.)).child( h_flex() + .relative() + .top(px(-1.)) .pb(px(6.)) .border_b_1() .border_color(if search_focused { - palette.primary + palette.ring } else { palette.border }) @@ -317,7 +360,6 @@ impl Panel { model: &providers::PickerModel, jump: Option, highlighted: bool, - show_provider: bool, palette: Palette, cx: &App, ) -> impl IntoElement { @@ -380,7 +422,7 @@ impl Panel { .h(px(ROW_HEIGHT)) .w_full() .flex_shrink_0() - .gap(px(6.)) + .gap_2() .px_2() .items_center() .rounded_sm() @@ -391,54 +433,75 @@ impl Panel { .hover(move |style| style.bg(palette.hover)) }) .when(!usable, |row| row.opacity(0.45)) - .when(show_provider, |row| { - row.child(provider_mark(&instance.mark, 1., cx)) - }) + .child(provider_mark(&instance.mark, 1., cx)) .child( - div() + h_flex() + .flex_1() .min_w_0() - .truncate() - .line_height(px(22.)) - .child(model.label.clone()), + .gap_2() + .items_center() + .child( + div() + .min_w_0() + .truncate() + .line_height(px(19.25)) + .child(model.label.clone()), + ) + .when(chosen, |row| { + row.child( + Icon::new(IconName::Check) + .size(px(14.)) + .flex_shrink_0() + .text_color(palette.foreground), + ) + }) + .children(model.sub_provider.clone().map(|name| { + div() + .min_w_0() + .truncate() + .text_xs() + .text_color(palette.muted.opacity(0.7)) + .child(name) + })) + .children(model.new.then(|| { + div() + .flex_shrink_0() + .rounded(px(4.)) + .border_1() + .border_color(palette.update.opacity(0.35)) + .bg(palette.update.opacity(0.15)) + .px(px(2.)) + .py_px() + .text_size(px(10.)) + .line_height(px(10.)) + .font_weight(FontWeight::BOLD) + .text_color(palette.update_text) + .child("NEW") + })), ) - .children(model.sub_provider.clone().map(|name| { - div() - .flex_shrink_0() - .text_xs() - .text_color(palette.muted.opacity(0.7)) - .child(name) - })) - .children(model.new.then(|| { - div() - .flex_shrink_0() - .rounded_sm() - .px_1() - .text_xs() - .text_color(palette.primary) - .bg(palette.primary.opacity(0.12)) - .child("New") - })) - .child(div().flex_1()) - .when(chosen, |row| { - row.child( - Icon::new(IconName::Check) - .size(px(14.)) - .text_color(palette.foreground), - ) - }) - .when(usable, |row| row.child(star)) - .children(jump.map(|n| { - div() + .child( + h_flex() .flex_shrink_0() - .px(px(4.)) - .rounded_sm() - .border_1() - .border_color(palette.border) - .text_size(px(10.)) - .line_height(px(14.)) - .text_color(palette.muted) - .child(format!("\u{2318}{}", n + 1)) - })) + .gap(px(6.)) + .items_center() + .when(usable, |row| row.child(star)) + .children(jump.map(|n| { + div() + .flex() + .h(px(20.)) + .min_w(px(20.)) + .flex_shrink_0() + .items_center() + .justify_center() + .px_1() + .rounded(px(4.)) + .bg(palette.kbd) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(palette.muted) + .child(format!("\u{2318}{}", n + 1)) + })), + ) .when(usable, |row| { row.on_click(move |_, _, cx| { view.update(cx, |this, cx| this.pick_model(&id, &slug, cx)); @@ -469,33 +532,55 @@ impl Panel { if effort.levels.is_empty() { rows.push( div() + .flex() + .h(px(ROW_HEIGHT)) + .items_center() .px_2() - .py_1() - .text_xs() + .text_sm() .text_color(palette.muted) .child("No effort levels") .into_any_element(), ); } } + let separated = controls.effort.is_some(); + let mut others = Vec::new(); for control in &controls.others { - rows.push( + others.push( self.option_row(index, control, on_column && self.effort == index, palette) .into_any_element(), ); index += 1; } + if !others.is_empty() { + rows.push( + v_flex() + .gap(px(2.)) + .when(separated, |group| { + group + .mt_1() + .pt_1() + .border_t_1() + .border_color(palette.border) + }) + .children(others) + .into_any_element(), + ); + } v_flex() .w(px(EFFORT_WIDTH)) .h_full() .flex_shrink_0() + .bg(palette.fill) + .rounded_tr(palette.radius) .border_l_1() .border_color(palette.border) .child( div().px_3().pt(px(10.)).child( h_flex() .h(px(33.)) - .mt(px(-1.)) + .relative() + .top(px(-1.)) .gap_2() .pb(px(6.)) .items_center() @@ -558,7 +643,13 @@ impl Panel { this.picker_set_option(&id, value.clone(), cx) }); }) - .child(div().min_w_0().truncate().child(level.label.clone())) + .child( + div() + .flex_1() + .min_w_0() + .truncate() + .child(level.label.clone()), + ) .when(level.is_default && !level.selected, |row| { row.child( div() @@ -567,7 +658,6 @@ impl Panel { .child("Default"), ) }) - .child(div().flex_1()) .when(level.selected, |row| { row.child( Icon::new(IconName::Check) @@ -665,6 +755,8 @@ impl Panel { .border_t_1() .border_color(palette.border) .bg(palette.fill) + .rounded_bl(palette.radius) + .rounded_br(palette.radius) .children(speed) .child(div().ml_auto().child(permissions_popover( &self.permissions, @@ -682,6 +774,27 @@ impl Panel { } } +/// The rail's tooltip: the instance's mark beside its name. +fn rail_tooltip( + label: String, + mark: Option, +) -> impl Fn(&mut Window, &mut App) -> gpui::AnyView + 'static { + move |window, cx| { + let (label, mark) = (label.clone(), mark.clone()); + Tooltip::element(move |_, cx| { + h_flex() + .gap_2() + .items_center() + .children(mark.as_ref().map(|mark| provider_mark(mark, 1., cx))) + .child(label.clone()) + }) + .text_xs() + .py_1() + .rounded(px(6.)) + .build(window, cx) + } +} + /// `PermissionsMenu`'s trigger: `h-7 gap-1.5 rounded-md px-2 text-xs`, the mode's icon, its name, /// and a chevron. #[derive(IntoElement)] diff --git a/apps/desktop-gpui/src/ui/run_on_menu.rs b/apps/desktop-gpui/src/ui/run_on_menu.rs index 63e7bc4d401e..feb9f0bb7f26 100644 --- a/apps/desktop-gpui/src/ui/run_on_menu.rs +++ b/apps/desktop-gpui/src/ui/run_on_menu.rs @@ -2,9 +2,9 @@ //! control. With one environment it is the web's locked label; with several it is a select whose //! rows name the environments that have the project, plus "Add to another machine". //! -//! A GPUI draft can only run on the environment the window is attached to, and the app has no -//! add-project-to-machine flow, so the other environments and "Add to another machine" render as -//! the web draws them but do nothing (the web disables a row without `onEnvironmentChange`). +//! A GPUI draft can only run on the environment the window is attached to, so the other +//! environments render as the web draws them but do nothing (the web disables a row without +//! `onEnvironmentChange`). "Add to another machine" opens the clone dialog. use super::composer_menu::{self, OnClick, OnHover, Row, RowState}; use super::*; @@ -20,12 +20,15 @@ pub(super) struct Menu { pub view: Entity, pub current: Env, pub others: Vec, + /// `addTargets.length > 0`: a connected machine can still be given the project. + pub can_add: bool, pub highlight: usize, } -/// `hasMultipleEnvironments`: the select exists only when another environment has the project. -pub(super) fn is_select(others: &[RemoteMember]) -> bool { - !others.is_empty() +/// `hasMultipleEnvironments`: the select exists when another environment has the project, or +/// when the project can be added to one. +pub(super) fn is_select(others: &[RemoteMember], can_add: bool) -> bool { + !others.is_empty() || can_add } /// Rows before "Add to another machine": the environments, current first. @@ -99,21 +102,25 @@ fn content(menu: &Menu, cx: &App) -> gpui::Div { }) .collect(); let add = add_index(menu); - rows.push(Row { - id: ("host-row", add), - state: RowState { - selected: false, - lit: add == menu.highlight, - // The add-project-to-machine dialog is not ported, so the row only closes the menu. - disabled: false, - quiet: true, - }, - body: composer_menu::plain_body(Icons::Plus, "Add to another machine", cx) - .into_any_element(), - on_hover: hover(&menu.view, add), - on_click: close(&menu.view), - }); - composer_menu::popup("Run on", None, rows, cx) + if menu.can_add { + let view = menu.view.clone(); + rows.push(Row { + id: ("host-row", add), + state: RowState { + selected: false, + lit: add == menu.highlight, + disabled: false, + quiet: true, + }, + body: composer_menu::plain_body(Icons::Plus, "Add to another machine", cx) + .into_any_element(), + on_hover: hover(&menu.view, add), + on_click: Box::new(move |window: &mut Window, cx: &mut App| { + view.update(cx, |this, cx| this.open_add_to_machine(window, cx)); + }), + }); + } + composer_menu::popup(Some("Run on"), None, rows, cx) } #[cfg(test)] @@ -122,10 +129,14 @@ mod tests { #[test] fn the_select_needs_another_environment() { - assert!(!is_select(&[])); - assert!(is_select(&[RemoteMember { - label: "Other".to_owned(), - machine: MachineKind::Cloud, - }])); + assert!(!is_select(&[], false)); + assert!(is_select(&[], true)); + assert!(is_select( + &[RemoteMember { + label: "Other".to_owned(), + machine: MachineKind::Cloud, + }], + false + )); } } diff --git a/apps/desktop-gpui/src/ui/settings.rs b/apps/desktop-gpui/src/ui/settings.rs index abedb437f399..60b1c232ec56 100644 --- a/apps/desktop-gpui/src/ui/settings.rs +++ b/apps/desktop-gpui/src/ui/settings.rs @@ -10,6 +10,8 @@ mod diffs_panel; mod labs_panel; mod scope_inheritance; mod scope_pickers; +mod secret_field; +mod source_control_panel; pub(crate) mod tokens; use super::*; @@ -29,6 +31,7 @@ use diffs_panel::DiffsPanel; use gpui::{ClickEvent, FocusHandle, KeyBinding, KeyDownEvent, ScrollHandle, Subscription}; use labs_panel::{LabsEvent, LabsPanel}; use scope_pickers::ScopeBar; +use source_control_panel::SourceControlPanel; use std::rc::Rc; use tokens::Tokens; @@ -168,6 +171,7 @@ pub struct SettingsDialog { typography: Entity, labs: Entity, devices: Entity, + source_control: Entity, scope: Entity, confirm_restore: bool, scroll: ScrollHandle, @@ -183,6 +187,8 @@ impl SettingsDialog { ) -> Self { let scope = cx.new(|cx| ScopeBar::new(window, cx)); let devices = cx.new(|cx| DevicesPanel::new(actions.clone(), scope.clone(), cx)); + let source_control = + cx.new(|cx| SourceControlPanel::new(actions.clone(), scope.clone(), window, cx)); let labs = cx.new(|cx| LabsPanel::new(actions, scope.clone(), cx)); let labs_events = cx.subscribe_in(&labs, window, |this, _, event: &LabsEvent, _, cx| { let LabsEvent::OpenSection(section) = event; @@ -216,6 +222,7 @@ impl SettingsDialog { typography: cx.new(|cx| AppearanceTypography::new(window, cx)), labs, devices, + source_control, scope, confirm_restore: false, scroll: ScrollHandle::new(), @@ -236,6 +243,7 @@ impl SettingsDialog { self.typography.update(cx, |panel, cx| panel.reload(cx)); self.labs.update(cx, |panel, cx| panel.reload(cx)); self.devices.update(cx, |panel, cx| panel.reload(cx)); + self.source_control.update(cx, |panel, cx| panel.reload(cx)); self.menu = None; self.advanced_open = false; self.confirm_restore = false; @@ -282,12 +290,34 @@ impl SettingsDialog { &mut self, config: crate::settings_devices::DeviceConfig, projects: crate::settings_devices::ProjectIds, + secret_sources: Vec, cx: &mut Context, ) { - self.devices + self.devices.update(cx, |panel, cx| { + panel.set_data(config, projects, secret_sources, cx) + }); + } + + pub fn set_source_control_data( + &mut self, + config: crate::settings_source_control::SourceControlConfig, + projects: crate::settings_devices::ProjectIds, + cx: &mut Context, + ) { + self.source_control .update(cx, |panel, cx| panel.set_data(config, projects, cx)); } + pub fn on_source_control_call( + &mut self, + token: u64, + event: crate::status::CallEvent, + cx: &mut Context, + ) { + self.source_control + .update(cx, |panel, cx| panel.on_call(token, event, cx)); + } + pub fn on_devices_call( &mut self, token: u64, @@ -381,6 +411,12 @@ impl SettingsDialog { { return; } + if self + .source_control + .update(cx, |panel, cx| panel.close_menu(window, cx)) + { + return; + } if self .diffs .update(cx, |panel, cx| panel.close_menu(window, cx)) @@ -1081,11 +1117,18 @@ impl SettingsDialog { self.devices .update(cx, |panel, cx| panel.set_wide(wide, cx)); vec![self.devices.clone().into_any_element()] + } else if self.section == Section::SourceControl { + self.source_control + .update(cx, |panel, cx| panel.set_wide(wide, cx)); + vec![self.source_control.clone().into_any_element()] } else { Vec::new() }; // The web hides the scope sentence on device-only tabs; the tabs that apply a scope list here. - let scoped = matches!(self.section, Section::Labs | Section::Devices); + let scoped = matches!( + self.section, + Section::Labs | Section::Devices | Section::SourceControl + ); div() .w_full() .flex() diff --git a/apps/desktop-gpui/src/ui/settings/appearance_panel.rs b/apps/desktop-gpui/src/ui/settings/appearance_panel.rs index eeb68e02d5b2..61f573003d7f 100644 --- a/apps/desktop-gpui/src/ui/settings/appearance_panel.rs +++ b/apps/desktop-gpui/src/ui/settings/appearance_panel.rs @@ -106,6 +106,7 @@ pub struct AppearancePanel { preview_open: bool, wide: bool, content_width: f32, + scale: u32, } impl AppearancePanel { @@ -119,6 +120,7 @@ impl AppearancePanel { preview_open: true, wide: true, content_width: 896., + scale: 2, } } @@ -592,7 +594,7 @@ impl AppearancePanel { this.update_theme(|t| t.pick_half(id, dark), cx) }) }) - .child(orb(id.orb(dark), dark, cx)) + .child(orb(id, dark, self.scale, cx)) .when(picked, |button| { button .child( @@ -791,6 +793,7 @@ impl AppearancePanel { impl Render for AppearancePanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + self.scale = window.scale_factor().ceil().clamp(1., 4.) as u32; let s = self.settings.clone(); let d = AppearanceSettings::default(); let wide = self.wide; diff --git a/apps/desktop-gpui/src/ui/settings/appearance_themes.rs b/apps/desktop-gpui/src/ui/settings/appearance_themes.rs index a626815353dc..94296d0e14de 100644 --- a/apps/desktop-gpui/src/ui/settings/appearance_themes.rs +++ b/apps/desktop-gpui/src/ui/settings/appearance_themes.rs @@ -1,10 +1,14 @@ //! The wireframe and orb drawings of the Appearance tab: `ThemeWireframe.tsx` and -//! `ThemePreviewCircles.tsx`. gpui has no radial gradients, so each orb layer is a stack of -//! concentric translucent circles that steps through the same alpha ramp. +//! `ThemePreviewCircles.tsx`. gpui has no radial gradients or blur, so each orb is rasterized on +//! the CPU once per theme, mode and device scale and drawn as an image. use super::*; -use crate::settings_appearance::{ColorMode, OrbPalette, ThemeChoice, ThemeId, mix}; -use gpui::{BoxShadow, Hsla, Rgba, point, relative, rgb}; +use crate::settings_appearance::{ColorMode, Paint, ThemeChoice, ThemeId, mix_oklab}; +use gpui::{BoxShadow, Hsla, ImageSource, RenderImage, Rgba, img, point, relative, rgb}; +use image::{Frame, RgbaImage}; +use smallvec::smallvec; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, OnceLock}; fn color(rgb: [f32; 3], a: f32) -> Hsla { Rgba { @@ -26,56 +30,16 @@ struct Wire { action: Hsla, } -/// The default theme's roles are exact; the others derive theirs from their orb colors. fn wire(theme: ThemeId, dark: bool) -> Wire { - if theme == ThemeId::Default { - let hex = |value: u32| -> Hsla { rgb(value).into() }; - return if dark { - Wire { - canvas: hex(0x0a0a0a), - sidebar: hex(0x0f0f10), - surface: hex(0x121212), - accent_surface: hex(0x27272a), - message_surface: hex(0x27272a), - action: hex(0x8b9cff), - } - } else { - Wire { - canvas: hex(0xfcfcfc), - sidebar: hex(0xfafafa), - surface: hex(0xffffff), - accent_surface: hex(0xf4f4f5), - message_surface: hex(0xe4e4e7), - action: hex(0x4f46e5), - } - }; - } - let palette = theme.orb(dark); - let (canvas, accent, action) = ( - palette.canvas.rgb(), - palette.accent.rgb(), - palette.action.rgb(), - ); - let (white, black) = ([1.; 3], [0.; 3]); - let tinted = |weight: f32| color(mix(canvas, accent, weight), 1.); - if dark { - Wire { - canvas: color(canvas, 1.), - sidebar: color(mix(canvas, white, 0.98), 1.), - surface: color(mix(canvas, white, 0.96), 1.), - accent_surface: tinted(0.8), - message_surface: tinted(0.7), - action: color(action, 1.), - } - } else { - Wire { - canvas: color(canvas, 1.), - sidebar: color(mix(canvas, black, 0.98), 1.), - surface: color(mix(canvas, white, 0.2), 1.), - accent_surface: tinted(0.88), - message_surface: tinted(0.8), - action: color(action, 1.), - } + let (palette, roles) = (theme.orb(dark), theme.roles(dark)); + let paint = |paint: Paint| color(paint.rgb(), 1.); + Wire { + canvas: paint(palette.canvas), + sidebar: paint(roles.sidebar), + surface: paint(roles.surface), + accent_surface: paint(roles.accent_surface), + message_surface: paint(roles.message_surface), + action: paint(palette.action), } } @@ -243,81 +207,222 @@ pub(super) fn wireframe(mode: ColorMode, choice: ThemeChoice, cx: &App) -> gpui: } } -/// Alpha of a gradient with `(offset, alpha)` stops at distance fraction `t`. -fn ramp(stops: &[(f32, f32)], t: f32) -> f32 { - let mut previous = stops[0]; - for &stop in stops { - if t <= stop.0 { - let span = (stop.0 - previous.0).max(f32::EPSILON); - return previous.1 + (stop.1 - previous.1) * ((t - previous.0) / span).clamp(0., 1.); +/// The orb's inner box and clip circle, in logical pixels (`size-14` minus the 2px border). +const INNER: f32 = 52.; +const RADIUS: f32 = INNER / 2.; +/// `THEME_PREVIEW_RENDER_SPECS`: blur sigma and the scale applied after the blur. +const BLUR: f32 = 3.; +const ZOOM: f32 = 1.1; + +/// One radial gradient layer: a constant color whose alpha follows `(offset, alpha)` stops by the +/// distance from `center` as a fraction of the farthest-corner reach. +struct Radial { + color: [f32; 3], + center: (f32, f32), + stops: [(f32, f32); 3], +} + +impl Radial { + fn alpha(&self, x: f32, y: f32) -> f32 { + let (cx, cy) = (self.center.0 * INNER, self.center.1 * INNER); + let reach = [(0., 0.), (INNER, 0.), (0., INNER), (INNER, INNER)] + .iter() + .map(|(px, py)| ((px - cx).powi(2) + (py - cy).powi(2)).sqrt()) + .fold(0., f32::max); + let t = ((x - cx).powi(2) + (y - cy).powi(2)).sqrt() / reach; + let mut previous = self.stops[0]; + for stop in self.stops { + if t <= stop.0 { + let span = (stop.0 - previous.0).max(f32::EPSILON); + return previous.1 + + (stop.1 - previous.1) * ((t - previous.0) / span).clamp(0., 1.); + } + previous = stop; } - previous = stop; + previous.1 } - previous.1 } -/// One radial gradient as concentric circles, outermost first. -fn gradient( - rgb: [f32; 3], - center: (f32, f32), - stops: &[(f32, f32)], - steps: usize, - size: f32, -) -> Vec { - let (cx, cy) = (center.0 * size, center.1 * size); - let reach = [(0., 0.), (size, 0.), (0., size), (size, size)] - .iter() - .map(|(x, y)| ((x - cx).powi(2) + (y - cy).powi(2)).sqrt()) - .fold(0., f32::max); - let end = stops[stops.len() - 1].0; - let mut circles = Vec::new(); - let mut outer = 0.; - for step in 0..steps { - let radius = reach * end * (1. - step as f32 / steps as f32); - let next = reach * end * (1. - (step + 1) as f32 / steps as f32); - let t = ((radius + next) / 2.) / reach; - let total = ramp(stops, t).min(0.98); - let alpha = (1. - (1. - total) / (1. - outer)).max(0.); - outer = outer + alpha * (1. - outer); - circles.push( - div() - .absolute() - .left(px(cx - radius)) - .top(px(cy - radius)) - .size(px(radius * 2.)) - .rounded_full() - .bg(color(rgb, alpha)), - ); +/// Coverage of a pixel whose center lies `distance` logical pixels inside an edge. +fn coverage(distance: f32, scale: f32) -> f32 { + (distance * scale + 0.5).clamp(0., 1.) +} + +fn noise(x: usize, y: usize) -> f32 { + let mut h = (x as u32).wrapping_mul(0x9e37_79b1) ^ (y as u32).wrapping_mul(0x85eb_ca6b); + h ^= h >> 15; + h = h.wrapping_mul(0x2c1b_3c6d); + h ^= h >> 12; + (h & 0xffff) as f32 / 65536. +} + +/// A separable Gaussian blur of premultiplied pixels; outside the buffer is transparent. +fn blur(pixels: &mut [[f32; 4]], size: usize, sigma: f32) { + let radius = (sigma * 3.).ceil() as isize; + let mut kernel: Vec = (-radius..=radius) + .map(|i| (-((i * i) as f32) / (2. * sigma * sigma)).exp()) + .collect(); + let total: f32 = kernel.iter().sum(); + kernel.iter_mut().for_each(|w| *w /= total); + let mut scratch = vec![[0.; 4]; pixels.len()]; + for horizontal in [true, false] { + for y in 0..size { + for x in 0..size { + let mut sum = [0.; 4]; + for (tap, weight) in kernel.iter().enumerate() { + let offset = tap as isize - radius; + let (sx, sy) = if horizontal { + (x as isize + offset, y as isize) + } else { + (x as isize, y as isize + offset) + }; + if (0..size as isize).contains(&sx) && (0..size as isize).contains(&sy) { + let source = pixels[sy as usize * size + sx as usize]; + for channel in 0..4 { + sum[channel] += source[channel] * weight; + } + } + } + scratch[y * size + x] = sum; + } + } + pixels.copy_from_slice(&scratch); } - circles } -/// The 56px orb (`ThemePreviewCircles`): the canvas tinted to 80% of the way to the page color, -/// the accent glowing from one corner and the message action from the opposite one. -pub(super) fn orb(palette: OrbPalette, dark: bool, cx: &App) -> gpui::Div { - const INNER: f32 = 52.; - let theme = Tokens::of(cx); +fn sample(pixels: &[[f32; 4]], size: usize, x: f32, y: f32) -> [f32; 4] { + let (x0, y0) = (x.floor(), y.floor()); + let (fx, fy) = (x - x0, y - y0); + let at = |dx: isize, dy: isize| { + let (px, py) = (x0 as isize + dx, y0 as isize + dy); + if (0..size as isize).contains(&px) && (0..size as isize).contains(&py) { + pixels[py as usize * size + px as usize] + } else { + [0.; 4] + } + }; + let (a, b, c, d) = (at(0, 0), at(1, 0), at(0, 1), at(1, 1)); + std::array::from_fn(|i| { + (a[i] * (1. - fx) + b[i] * fx) * (1. - fy) + (c[i] * (1. - fx) + d[i] * fx) * fy + }) +} + +/// `ThemePreviewCircle` as straight RGBA: the oklab-tinted canvas under the accent and action +/// radial layers, clipped to the circle, blurred, zoomed about the center, then clipped again +/// with the 1px edge ring showing through the blurred rim. +fn rasterize(theme: ThemeId, dark: bool, scale: u32) -> Vec { + let palette = theme.orb(dark); let target = if dark { [9. / 255., 9. / 255., 11. / 255.] } else { [1.; 3] }; - let base = mix(palette.canvas.rgb(), target, 0.8); - let accent = palette.accent.rgb(); - let action = palette.action.rgb(); - let (accent_stops, accent_center, action_center) = if dark { + let base = mix_oklab(palette.canvas.rgb(), target, 0.8); + let (accent, action) = if dark { ( - [(0., 1.), (0.28, 0.62), (0.58, 0.)], - (0.28, 0.78), - (0.82, 0.18), + Radial { + color: palette.accent.rgb(), + center: (0.28, 0.78), + stops: [(0., 1.), (0.28, 0.62), (0.58, 0.)], + }, + Radial { + color: palette.action.rgb(), + center: (0.82, 0.18), + stops: [(0., 0.45), (0.55, 0.), (0.55, 0.)], + }, ) } else { ( - [(0., 1.), (0.28, 0.72), (0.58, 0.)], - (0.72, 0.22), - (0.18, 0.82), + Radial { + color: palette.accent.rgb(), + center: (0.72, 0.22), + stops: [(0., 1.), (0.28, 0.72), (0.58, 0.)], + }, + Radial { + color: palette.action.rgb(), + center: (0.18, 0.82), + stops: [(0., 0.45), (0.55, 0.), (0.55, 0.)], + }, ) }; + let s = scale as f32; + let size = (INNER * s) as usize; + let local = |index: usize| (index as f32 + 0.5) / s; + let radius_at = |x: f32, y: f32| ((x - RADIUS).powi(2) + (y - RADIUS).powi(2)).sqrt(); + + let mut layer = vec![[0.; 4]; size * size]; + for y in 0..size { + for x in 0..size { + let (u, v) = (local(x), local(y)); + let inside = coverage(RADIUS - radius_at(u, v), s); + let mut rgb = base; + for radial in [&action, &accent] { + rgb = mix_rgb(rgb, radial.color, radial.alpha(u, v)); + } + layer[y * size + x] = [rgb[0] * inside, rgb[1] * inside, rgb[2] * inside, inside]; + } + } + blur(&mut layer, size, BLUR * s); + + let (ring, ring_alpha) = if dark { (1., 0.14) } else { (0., 0.10) }; + let mut out = Vec::with_capacity(size * size * 4); + for y in 0..size { + for x in 0..size { + let (u, v) = (local(x), local(y)); + let radius = radius_at(u, v); + let from = |p: f32| (RADIUS + (p - RADIUS) / ZOOM) * s - 0.5; + let zoomed = sample(&layer, size, from(u), from(v)); + let edge = coverage(RADIUS - radius, s) * (1. - coverage(RADIUS - 1. - radius, s)); + let under = ring_alpha * edge; + let alpha = zoomed[3] + under * (1. - zoomed[3]); + let premultiplied: [f32; 3] = + std::array::from_fn(|i| zoomed[i] + ring * under * (1. - zoomed[3])); + let clip = coverage(RADIUS - radius, s); + let final_alpha = alpha * clip; + let jitter = noise(x, y) - 0.5; + let byte = |value: f32| (value * 255. + jitter).round().clamp(0., 255.) as u8; + let straight = |value: f32| if alpha > 0. { value / alpha } else { 0. }; + out.extend([ + byte(straight(premultiplied[0])), + byte(straight(premultiplied[1])), + byte(straight(premultiplied[2])), + (final_alpha * 255.).round().clamp(0., 255.) as u8, + ]); + } + } + out +} + +fn mix_rgb(from: [f32; 3], to: [f32; 3], alpha: f32) -> [f32; 3] { + std::array::from_fn(|i| from[i] * (1. - alpha) + to[i] * alpha) +} + +type OrbKey = (ThemeId, bool, u32); + +fn orb_image(theme: ThemeId, dark: bool, scale: u32) -> Arc { + static CACHE: OnceLock>>> = OnceLock::new(); + let mut cache = CACHE + .get_or_init(Default::default) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + cache + .entry((theme, dark, scale)) + .or_insert_with(|| { + let side = INNER as u32 * scale; + let mut pixels = rasterize(theme, dark, scale); + // gpui samples BGRA. + for pixel in pixels.as_chunks_mut::<4>().0 { + pixel.swap(0, 2); + } + let image = RgbaImage::from_raw(side, side, pixels).expect("square RGBA buffer"); + Arc::new(RenderImage::new(smallvec![Frame::new(image)])) + }) + .clone() +} + +/// The 56px orb: the rasterized `ThemePreviewCircle` inside its 2px page-colored border and drop +/// shadow. The image is already clipped to the circle, so nothing paints outside it. +pub(super) fn orb(theme: ThemeId, dark: bool, scale: u32, cx: &App) -> gpui::Div { let shadow = BoxShadow { color: gpui::black().opacity(if dark { 0.18 } else { 0.08 }), offset: point(px(0.), px(1.)), @@ -326,21 +431,35 @@ pub(super) fn orb(palette: OrbPalette, dark: bool, cx: &App) -> gpui::Div { inset: false, }; div() - .relative() .size(px(56.)) .flex_shrink_0() - .overflow_hidden() .rounded_full() .border_2() - .border_color(theme.background) - .bg(color(base, 1.)) + .border_color(Tokens::of(cx).background) .shadow(vec![shadow]) - .children(gradient(accent, accent_center, &accent_stops, 12, INNER)) - .children(gradient( - action, - action_center, - &[(0., 0.45), (0.55, 0.)], - 6, - INNER, - )) + .child(img(ImageSource::Render(orb_image(theme, dark, scale))).size(px(INNER))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn alpha_at(pixels: &[u8], size: usize, x: usize, y: usize) -> u8 { + pixels[(y * size + x) * 4 + 3] + } + + #[test] + fn orb_is_clipped_to_the_circle() { + for theme in ThemeId::ALL { + for dark in [false, true] { + let pixels = rasterize(theme, dark, 2); + let size = 104; + for (x, y) in [(0, 0), (size - 1, 0), (0, size - 1), (size - 1, size - 1)] { + assert_eq!(alpha_at(&pixels, size, x, y), 0, "{theme:?} corner"); + } + assert_eq!(alpha_at(&pixels, size, 52, 52), 255, "{theme:?} center"); + assert!(alpha_at(&pixels, size, 52, 0) < 255, "{theme:?} rim"); + } + } + } } diff --git a/apps/desktop-gpui/src/ui/settings/components.rs b/apps/desktop-gpui/src/ui/settings/components.rs index 17365d1fd20b..7bc4bba8a36b 100644 --- a/apps/desktop-gpui/src/ui/settings/components.rs +++ b/apps/desktop-gpui/src/ui/settings/components.rs @@ -50,19 +50,42 @@ fn focus_ring(radius: f32, cx: &App) -> gpui::Div { /// `SettingsSection` (settingsLayout.tsx): `space-y-2.5`, the title row `min-h-7 px-4`, the title /// `text-base font-semibold`. -pub(super) fn section(title: &'static str, group: impl IntoElement) -> gpui::Div { +pub(super) fn section(title: impl IntoElement, group: impl IntoElement) -> gpui::Div { + section_with_action(title, None, group) +} + +/// `SettingsSection` with a `headerAction` at the right end of the title row. +pub(super) fn section_with_action( + title: impl IntoElement, + action: Option, + group: impl IntoElement, +) -> gpui::Div { v_flex() .gap(px(10.)) .child( - h_flex().min_h(px(28.)).items_start().px_4().child( - h_flex() - .min_h(px(28.)) - .items_center() - .gap_2() - .text_base() - .font_weight(FontWeight::SEMIBOLD) - .child(title), - ), + h_flex() + .min_h(px(28.)) + .items_start() + .justify_between() + .gap_4() + .px_4() + .child( + h_flex() + .min_h(px(28.)) + .items_center() + .gap_2() + .text_base() + .font_weight(FontWeight::SEMIBOLD) + .child(title), + ) + .child( + h_flex() + .min_h(px(28.)) + .min_w(px(28.)) + .items_center() + .justify_end() + .children(action), + ), ) .child(group) } @@ -229,6 +252,17 @@ pub(super) fn reset_button( on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, window: &mut Window, cx: &mut App, +) -> AnyElement { + reset_button_labeled(id, "Reset to default", on_click, window, cx) +} + +/// `reset_button` with the tooltip of a `SettingResetButton` that has a `label` or a custom one. +pub(super) fn reset_button_labeled( + id: &'static str, + label: &'static str, + on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + window: &mut Window, + cx: &mut App, ) -> AnyElement { let key = ElementId::from(id); let handle = tab_stop(&key, window, cx); @@ -240,7 +274,7 @@ pub(super) fn reset_button( let theme = Tokens::of(cx); let (muted, hover, foreground) = (theme.muted_foreground, theme.accent, theme.foreground); let ring = focus_ring(4., cx); - let popup = shown.then(|| gpui::deferred(tip_popup("Reset to default", cx))); + let popup = shown.then(|| gpui::deferred(tip_popup(label, cx))); let (hovered_tip, clicked_tip) = (tip.clone(), tip.clone()); div() .id(key) @@ -292,13 +326,13 @@ pub(super) fn reset_button( /// `Switch` (switch.tsx) at the desktop size: a 30x18 track with 2px padding and a 14px thumb /// that travels 12px. Space and Enter toggle it. pub(super) fn switch( - id: &'static str, + id: impl Into, checked: bool, on_toggle: Toggle, window: &mut Window, cx: &mut App, ) -> AnyElement { - let key = ElementId::from(id); + let key: ElementId = id.into(); let handle = tab_stop(&key, window, cx); let visible = focus_visible(&handle, window); let theme = Tokens::of(cx); @@ -357,10 +391,17 @@ pub(super) struct SelectTrigger { open: bool, on_key: KeyHandler, leading: Option, + content: Option, auto_width: bool, } impl SelectTrigger { + /// Replaces the label with an element, as `SelectValue` renders a function child. + pub(super) fn content(mut self, content: AnyElement) -> Self { + self.content = Some(content); + self + } + /// `w-auto`: the trigger is as wide as its label instead of `width`. pub(super) fn auto_width(mut self) -> Self { self.auto_width = true; @@ -374,13 +415,13 @@ impl SelectTrigger { } pub(super) fn new( - id: &'static str, + id: impl Into, label: impl Into, width: f32, handle: FocusHandle, on_key: KeyHandler, ) -> Self { - let id = ElementId::from(id); + let id = id.into(); Self { id, label: label.into(), @@ -389,6 +430,7 @@ impl SelectTrigger { open: false, on_key, leading: None, + content: None, auto_width: false, } } @@ -473,7 +515,10 @@ impl RenderOnce for SelectTrigger { .min_w_0() .when(!self.auto_width, |label| label.flex_1()) .truncate() - .child(self.label), + .child( + self.content + .unwrap_or_else(|| self.label.into_any_element()), + ), ) .child( div() diff --git a/apps/desktop-gpui/src/ui/settings/devices_panel.rs b/apps/desktop-gpui/src/ui/settings/devices_panel.rs index 14a408b6b4b8..a1fa4ea1e8cb 100644 --- a/apps/desktop-gpui/src/ui/settings/devices_panel.rs +++ b/apps/desktop-gpui/src/ui/settings/devices_panel.rs @@ -4,7 +4,7 @@ //! follow the Labs tab: optimistic, put back when the call fails. mod editors; -mod widgets; +pub(in crate::ui::settings) mod widgets; use super::components::{Toggle, group, reset_button, section, switch}; use super::scope_inheritance::{Marker, PopTrigger, marker_row, popup_shell}; @@ -86,6 +86,7 @@ pub struct DevicesPanel { add_menu: bool, row_menu: Option, editor: Option, + secret_sources: Vec, _subscriptions: Vec, } @@ -113,6 +114,7 @@ impl DevicesPanel { wide: true, config: DeviceConfig::default(), projects: devices::ProjectIds::new(), + secret_sources: Vec::new(), state: None, busy: None, versions: None, @@ -139,9 +141,16 @@ impl DevicesPanel { &mut self, config: DeviceConfig, projects: devices::ProjectIds, + secret_sources: Vec, cx: &mut Context, ) { let mut changed = self.projects != projects; + if self.secret_sources != secret_sources { + self.secret_sources = secret_sources; + if let Some(editor) = &self.editor { + editor.set_sources(&self.secret_sources, cx); + } + } self.projects = projects; if self.pending.is_empty() && self.config != config { self.config = config; @@ -484,7 +493,14 @@ impl DevicesPanel { ) { self.add_menu = false; self.row_menu = None; - let editor = Editor::open(kind, existing, &self.config, window, cx); + let editor = Editor::open( + kind, + existing, + &self.config, + &self.secret_sources, + window, + cx, + ); let account = editor.wants_account(); self.editor = Some(editor); if account { @@ -678,6 +694,7 @@ impl DevicesPanel { layers: devices::layers(key, &ctx.label, ctx.in_project, project, environment), machine: ctx.machine, environment: ctx.label.clone(), + groups: Vec::new(), } } diff --git a/apps/desktop-gpui/src/ui/settings/devices_panel/editors.rs b/apps/desktop-gpui/src/ui/settings/devices_panel/editors.rs index 94dc9c31a723..4d29179b251f 100644 --- a/apps/desktop-gpui/src/ui/settings/devices_panel/editors.rs +++ b/apps/desktop-gpui/src/ui/settings/devices_panel/editors.rs @@ -2,6 +2,7 @@ //! SimlockDeviceHostEditor.tsx) as one dialog: their drafts, checks and the host-list writes. use super::super::scope_inheritance::{PopTrigger, popup_shell}; +use super::super::secret_field::{SecretEvent, SecretField}; use super::super::tokens::Tokens; use super::super::*; use super::DevicesPanel; @@ -9,6 +10,7 @@ use super::widgets::{ Variant, button, dialog, field, icon_button, labeled, menu_item, note, problem, spinner, warning, }; +use crate::secret_source::SecretSource; use crate::settings_devices::{ self as devices, AddKind, Availability, DeviceConfig, EasHost, SimlockHost, Source, SshHost, }; @@ -44,7 +46,7 @@ enum Account { enum Token { Keep, - Set(String), + Set(Value), Clear, } @@ -121,6 +123,8 @@ pub(super) struct Editor { existing: Option, id: String, fields: Vec>, + /// The token input; the matching entry of `fields` stays unused. + token: Entity, options_open: bool, pub(super) seq: u64, check: Check, @@ -139,6 +143,7 @@ impl Editor { kind: AddKind, existing: Option<&Source>, _config: &DeviceConfig, + sources: &[SecretSource], window: &mut Window, cx: &mut Context, ) -> Self { @@ -229,6 +234,25 @@ impl Editor { input }) .collect(); + let saved = match existing { + Some(Source::Eas(host)) if kind == AddKind::Eas => host.access_token.clone(), + Some(Source::Simlock(host)) if kind == AddKind::Simlock => host.agent_token.clone(), + _ => Value::Null, + }; + let token = cx.new(|cx| SecretField::new("device-token", "Token", window, cx)); + token.update(cx, |field, cx| { + field.set_sources(sources, cx); + field.set_can_check(false, cx); + field.load(&saved, window, cx); + }); + subscriptions.push(cx.subscribe( + &token, + |_: &mut DevicesPanel, _, event: &SecretEvent, cx| { + if matches!(event, SecretEvent::Changed | SecretEvent::Commit) { + cx.notify(); + } + }, + )); let first = fields[0].read(cx).focus_handle(cx).clone(); window.focus(&first, cx); Self { @@ -236,6 +260,7 @@ impl Editor { existing: existing.cloned(), id, fields, + token, options_open, seq: 0, check: Check::Idle, @@ -323,14 +348,7 @@ impl Editor { /// Saved (kept), new, or cleared, as the secret field resolves them. fn token(&self, cx: &App) -> Token { - let typed = self.text(2, cx); - let typed = if self.kind == AddKind::Eas { - self.text(1, cx) - } else { - typed - }; - let typed = typed.trim().to_owned(); - if !typed.is_empty() { + if let Some(typed) = self.token.read(cx).submission(cx) { Token::Set(typed) } else if self.cleared || !self.saved_token { Token::Clear @@ -339,8 +357,9 @@ impl Editor { } } - fn token_index(&self) -> usize { - if self.kind == AddKind::Eas { 1 } else { 2 } + pub(super) fn set_sources(&self, sources: &[SecretSource], cx: &mut Context) { + self.token + .update(cx, |field, cx| field.set_sources(sources, cx)); } pub(super) fn start_test(&mut self, cx: &App) -> Option { @@ -478,10 +497,9 @@ impl Editor { } } - fn secret(&self, view: &Entity, window: &Window, cx: &App) -> AnyElement { - let index = self.token_index(); - let visible = !self.cleared && (self.saved_token || !self.text(index, cx).is_empty()); - let input = self.fields[index].clone(); + fn secret(&self, view: &Entity, _window: &Window, cx: &App) -> AnyElement { + let visible = !self.cleared && (self.saved_token || self.token.read(cx).has_draft(cx)); + let input = self.token.clone(); let clear = visible.then(|| { let view = view.clone(); icon_button( @@ -497,7 +515,7 @@ impl Editor { if let Some(editor) = &mut this.editor { editor.cleared = true; } - input.update(cx, |state, cx| state.set_value("", window, cx)); + input.update(cx, |field, cx| field.clear(window, cx)); cx.notify(); }) }) @@ -506,11 +524,7 @@ impl Editor { h_flex() .items_center() .gap_2() - .child( - div() - .flex_1() - .child(field(&self.fields[index], self.saving, window, cx)), - ) + .child(div().flex_1().child(self.token.clone())) .children(clear) .into_any_element() } diff --git a/apps/desktop-gpui/src/ui/settings/devices_panel/widgets.rs b/apps/desktop-gpui/src/ui/settings/devices_panel/widgets.rs index 89dde218ea65..da227fac6aa3 100644 --- a/apps/desktop-gpui/src/ui/settings/devices_panel/widgets.rs +++ b/apps/desktop-gpui/src/ui/settings/devices_panel/widgets.rs @@ -9,7 +9,7 @@ use gpui_component::input::Input; use gpui_component::spinner::Spinner; #[derive(Clone, Copy, PartialEq, Eq)] -pub(super) enum Variant { +pub(in crate::ui::settings) enum Variant { Primary, Outline, Ghost, @@ -18,13 +18,13 @@ pub(super) enum Variant { } /// `--warning-foreground` (index.css:1165, 1217): amber-700, amber-400 in dark. -pub(super) fn warning(theme: &Tokens) -> Hsla { +pub(in crate::ui::settings) fn warning(theme: &Tokens) -> Hsla { gpui::rgb(if theme.dark { 0xfbbf24 } else { 0xb45309 }).into() } /// `Button size="sm"` (`h-7 gap-1.5 rounded-lg px-[9px] text-sm`): the caller adds the content /// and `on_click`. -pub(super) fn button( +pub(in crate::ui::settings) fn button( id: impl Into, variant: Variant, enabled: bool, @@ -99,7 +99,7 @@ pub(super) fn button( } /// `Button size="icon-sm"` (`size-8 sm:size-7`). -pub(super) fn icon_button( +pub(in crate::ui::settings) fn icon_button( id: impl Into, variant: Variant, enabled: bool, @@ -109,7 +109,7 @@ pub(super) fn icon_button( } /// `Spinner` (spinner.tsx): the glyph without the continuous spin the app avoids. -pub(super) fn spinner(size: f32, color: Hsla) -> AnyElement { +pub(in crate::ui::settings) fn spinner(size: f32, color: Hsla) -> AnyElement { Spinner::new() .icon(Icon::new(Icons::LoaderCircle)) .color(color) @@ -161,7 +161,7 @@ pub(super) fn labeled(title: &'static str, body: impl IntoElement) -> gpui::Div } /// A line of `text-xs text-muted-foreground`. -pub(super) fn note(text: impl IntoElement, cx: &App) -> gpui::Div { +pub(in crate::ui::settings) fn note(text: impl IntoElement, cx: &App) -> gpui::Div { div() .text_xs() .line_height(px(16.)) @@ -170,7 +170,7 @@ pub(super) fn note(text: impl IntoElement, cx: &App) -> gpui::Div { } /// `p.text-xs.text-destructive[role=alert]`. -pub(super) fn problem(text: impl IntoElement, cx: &App) -> gpui::Div { +pub(in crate::ui::settings) fn problem(text: impl IntoElement, cx: &App) -> gpui::Div { div() .text_xs() .line_height(px(16.)) diff --git a/apps/desktop-gpui/src/ui/settings/scope_inheritance.rs b/apps/desktop-gpui/src/ui/settings/scope_inheritance.rs index de7d20d361e4..c62b8f2c1b50 100644 --- a/apps/desktop-gpui/src/ui/settings/scope_inheritance.rs +++ b/apps/desktop-gpui/src/ui/settings/scope_inheritance.rs @@ -67,11 +67,46 @@ pub(super) struct Marker { pub layers: Vec, pub machine: MachineKind, pub environment: String, + /// Environments in scope whose settings are loaded, grouped by resolved value. With fewer than + /// two groups the popover keeps the single-environment layout. + pub groups: Vec, +} + +/// Environments that resolve a setting to the same layers (`groups` of SettingInheritance.tsx). +#[derive(Clone)] +pub(super) struct MarkerGroup { + pub machines: Vec<(MachineKind, String)>, + pub layers: Vec, +} + +impl MarkerGroup { + fn value(&self) -> String { + self.winner() + .map_or_else(String::new, |layer| layer.value.clone()) + } + + fn winner(&self) -> Option<&Layer> { + self.layers.iter().find(|layer| layer.effective) + } + + fn decided_by(&self) -> String { + self.winner().map_or_else(String::new, layer_name) + } +} + +fn layer_name(layer: &Layer) -> String { + match layer.kind { + crate::settings_devices::LayerKind::Environment => "Environment".to_owned(), + _ => layer.label.clone(), + } } /// The popover body (SettingInheritance.tsx:263-329, one environment): a muted header with the /// machine icon and name, then the layers, the effective one highlighted and checked. -fn chain(marker: &Marker, cx: &App) -> gpui::Div { +fn chain(marker: &Marker, window: &mut Window, cx: &mut App) -> gpui::Div { + if marker.groups.len() > 1 { + return grouped(marker, window, cx); + } let theme = Tokens::of(cx); let header = h_flex() .items_center() @@ -86,62 +121,7 @@ fn chain(marker: &Marker, cx: &App) -> gpui::Div { .child(Icon::new(machine_icon(marker.machine)).size(px(14.))), ) .child(div().min_w_0().truncate().child(marker.environment.clone())); - let layers = v_flex() - .text_sm() - .children(marker.layers.iter().map(|layer| { - let label_color = if layer.effective { - theme.foreground - } else { - theme.muted_foreground - }; - let value_color = if layer.effective { - theme.foreground - } else if layer.set { - theme.muted_foreground - } else { - theme.muted_foreground.opacity(0.6) - }; - let label = match layer.kind { - crate::settings_devices::LayerKind::Environment => "Environment".to_owned(), - _ => layer.label.clone(), - }; - h_flex() - .items_center() - .justify_between() - .gap(px(12.)) - .rounded(px(6.)) - .px_2() - .py_1() - .when(layer.effective, |row| { - row.bg(theme.foreground.opacity(0.06)) - }) - .child( - div() - .min_w_0() - .truncate() - .text_color(label_color) - .when(layer.effective, |label| { - label.font_weight(FontWeight::MEDIUM) - }) - .child(label), - ) - .child( - h_flex() - .items_center() - .gap(px(6.)) - .text_color(value_color) - .child(div().max_w(px(128.)).truncate().child(layer.value.clone())) - .child( - div() - .size(px(14.)) - .flex_shrink_0() - .text_color(theme.primary) - .when(layer.effective, |slot| { - slot.child(Icon::new(Icons::Check).size(px(14.))) - }), - ), - ) - })); + let layers = layer_list(&marker.layers, cx); v_flex() .w(px(320.)) .max_w_full() @@ -151,6 +131,148 @@ fn chain(marker: &Marker, cx: &App) -> gpui::Div { .child(layers) } +/// `
    ` of the layers, the effective one highlighted and checked. +fn layer_list(layers: &[Layer], cx: &App) -> gpui::Div { + let theme = Tokens::of(cx); + v_flex().text_sm().children(layers.iter().map(|layer| { + let label_color = if layer.effective { + theme.foreground + } else { + theme.muted_foreground + }; + let value_color = if layer.effective { + theme.foreground + } else if layer.set { + theme.muted_foreground + } else { + theme.muted_foreground.opacity(0.6) + }; + let label = layer_name(layer); + h_flex() + .items_center() + .justify_between() + .gap(px(12.)) + .rounded(px(6.)) + .px_2() + .py_1() + .when(layer.effective, |row| { + row.bg(theme.foreground.opacity(0.06)) + }) + .child( + div() + .min_w_0() + .truncate() + .text_color(label_color) + .when(layer.effective, |label| { + label.font_weight(FontWeight::MEDIUM) + }) + .child(label), + ) + .child( + h_flex() + .items_center() + .gap(px(6.)) + .text_color(value_color) + .child(div().max_w(px(128.)).truncate().child(layer.value.clone())) + .child( + div() + .size(px(14.)) + .flex_shrink_0() + .text_color(theme.primary) + .when(layer.effective, |slot| { + slot.child(Icon::new(Icons::Check).size(px(14.))) + }), + ), + ) + })) +} + +/// Several environments: one collapsible section per resolved value, set apart by dividers. Each +/// header shows the value, the machines that share it and the layer that decides it. +fn grouped(marker: &Marker, window: &mut Window, cx: &mut App) -> gpui::Div { + let theme = Tokens::of(cx); + let mut body = v_flex().w(px(320.)).max_w_full(); + for (index, group) in marker.groups.iter().enumerate() { + let key = ElementId::from((marker.id, 100 + index)); + let open = window.use_keyed_state(key.clone(), cx, |_, _| false); + let expanded = *open.read(cx); + let machines = h_flex() + .flex_wrap() + .items_center() + .gap_x(px(8.)) + .text_xs() + .text_color(theme.muted_foreground) + .children(group.machines.iter().map(|(kind, label)| { + h_flex() + .min_w_0() + .items_center() + .gap(px(4.)) + .child(Icon::new(machine_icon(*kind)).size(px(14.))) + .child(div().min_w_0().truncate().child(label.clone())) + })); + let header = h_flex() + .id(ElementId::from((marker.id, 200 + index))) + .w_full() + .items_center() + .justify_between() + .gap(px(8.)) + .px_2() + .py_1() + .rounded(px(6.)) + .cursor_pointer() + .hover(move |style| style.bg(theme.accent)) + .on_click({ + let open = open.clone(); + move |_, window, cx| { + open.update(cx, |open, _| *open = !*open); + window.refresh(); + } + }) + .child( + v_flex() + .min_w_0() + .gap(px(2.)) + .child( + div() + .text_base() + .font_weight(FontWeight::MEDIUM) + .text_color(theme.foreground) + .child(group.value()), + ) + .child(machines), + ) + .child( + h_flex() + .flex_shrink_0() + .items_center() + .gap(px(4.)) + .text_xs() + .text_color(theme.muted_foreground) + .child(group.decided_by()) + .child( + Icon::new(if expanded { + Icons::ChevronDown + } else { + Icons::ChevronRight + }) + .size(px(14.)), + ), + ); + body = body.child( + v_flex() + .px(px(12.)) + .py(px(10.)) + .gap(px(6.)) + .when(index > 0, |section| { + section.border_t_1().border_color(theme.border.opacity(0.6)) + }) + .child(header) + .children(expanded.then(|| layer_list(&group.layers, cx))), + ); + } + body +} + /// The Layers button: `Button size="icon-micro" variant="ghost-muted"` (`size-5 rounded-sm`) /// holding a `size-3` icon, `text-primary` while overridden, with the summary as its tooltip. fn indicator(marker: &Marker, window: &mut Window, cx: &mut App) -> AnyElement { @@ -194,7 +316,7 @@ fn indicator(marker: &Marker, window: &mut Window, cx: &mut App) -> AnyElement { .appearance(false) .on_open_change(move |next, _, cx| toggled.update(cx, |open, _| *open = *next)) .trigger(PopTrigger::new(button)) - .content(move |_, _, cx| popup_shell(cx).child(chain(&marker, cx))) + .content(move |_, window, cx| popup_shell(cx).child(chain(&marker, window, cx))) .into_any_element() } @@ -212,6 +334,34 @@ pub(super) fn marker_row( wide: bool, window: &mut Window, cx: &mut App, +) -> gpui::Stateful { + marker_row_with( + id, + title, + description, + marker, + reset, + control, + None, + wide, + window, + cx, + ) +} + +/// `marker_row` with `children` under the row's columns (`pt-3 pb-1`), as `SettingsRow` takes them. +#[allow(clippy::too_many_arguments)] +pub(super) fn marker_row_with( + id: &'static str, + title: &'static str, + description: impl IntoElement, + marker: Option, + reset: Option, + control: AnyElement, + preview: Option, + wide: bool, + window: &mut Window, + cx: &mut App, ) -> gpui::Stateful { let theme = Tokens::of(cx); let slot = marker.map(|marker| { @@ -268,5 +418,11 @@ pub(super) fn marker_row( } else { v_flex().gap_3().child(text).child(control) }; - div().id(id).group(id).px_4().py_3().child(inner) + let framed = div().id(id).group(id).px_4(); + let framed = if preview.is_some() { + framed.pt_3().pb_1() + } else { + framed.py_3() + }; + framed.child(inner).children(preview) } diff --git a/apps/desktop-gpui/src/ui/settings/secret_field.rs b/apps/desktop-gpui/src/ui/settings/secret_field.rs new file mode 100644 index 000000000000..9aabdb6c6f03 --- /dev/null +++ b/apps/desktop-gpui/src/ui/settings/secret_field.rs @@ -0,0 +1,630 @@ +//! The secret source control (`SecretSourceInput.tsx`): a select of Value or one of the server's +//! secret providers, then a masked input for a value or a mono input for a reference, with an +//! optional check of the committed reference. The Source control and Devices tabs share it. + +use super::devices_panel::widgets::{Variant, icon_button, spinner}; +use super::scope_inheritance::PopTrigger; +use super::tokens::Tokens; +use super::*; +use crate::secret_source::{self as secret, SecretSource}; +use gpui::{Anchor, EventEmitter, FocusHandle, KeyDownEvent, Subscription}; +use gpui_component::input::{Input, InputEvent, InputState}; +use gpui_component::popover::Popover; +use serde_json::Value; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(in crate::ui::settings) enum SecretEvent { + /// The value, the source or the committed reference changed. + Changed, + /// The check button asked to resolve `(provider, reference)` on the server. + Check(String, String), + /// The user finished editing: a typed value lost focus, or a reference passed validation. + Commit, + /// Enter was pressed in the input. + Submit, +} + +/// `SecretCheck` (SecretSourceInput.tsx:60-64). +#[derive(Clone, Debug, PartialEq, Eq)] +pub(in crate::ui::settings) enum Check { + Idle, + Checking, + Resolved, + Failed(String), +} + +pub(in crate::ui::settings) struct SecretField { + id: SharedString, + sources: Vec, + /// The selected source; `None` is Value. + provider: Option, + /// The last reference that passed validation. + committed: Option<(String, String)>, + /// A literal is stored on the server; the input only replaces it. + configured: bool, + rejected: Option, + value: Entity, + reference: Entity, + menu: Option, + trigger: FocusHandle, + check: Check, + can_check: bool, + disabled: bool, + _subscriptions: Vec, +} + +/// `Input size="sm"` (input.tsx:24): a 28px box with a 9px gutter, `font-mono` for a reference. +pub(in crate::ui::settings) fn sm_field( + input: &Entity, + mono: bool, + invalid: bool, + disabled: bool, + window: &Window, + cx: &App, +) -> gpui::Div { + let theme = Tokens::of(cx); + let focused = input.read(cx).focus_handle(cx).contains_focused(window, cx); + let background = if theme.dark { + theme.input.opacity(0.32) + } else { + theme.background + }; + let border = if invalid { + theme.destructive.opacity(if focused { 0.64 } else { 0.36 }) + } else if focused { + theme.ring + } else { + theme.input + }; + div() + .w_full() + .min_w_0() + .h(px(28.)) + .px(px(9.)) + .rounded(px(8.)) + .border_1() + .border_color(border) + .bg(background) + .text_size(px(14.)) + .when(mono, |field| { + field.font_family(cx.theme().mono_font_family.clone()) + }) + .when(disabled, |field| field.opacity(0.64)) + .child( + Input::new(input) + .appearance(false) + .bordered(false) + .disabled(disabled) + .h(px(26.)) + .px_0(), + ) +} + +impl EventEmitter for SecretField {} + +impl SecretField { + pub(in crate::ui::settings) fn new( + id: impl Into, + _label: impl Into, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let value = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Not set") + .masked(true) + }); + let reference = cx.new(|cx| InputState::new(window, cx)); + let subscriptions = vec![ + cx.subscribe_in( + &value, + window, + |this, _, event: &InputEvent, _, cx| match event { + InputEvent::Change => { + cx.emit(SecretEvent::Changed); + cx.notify(); + } + InputEvent::Blur | InputEvent::PressEnter { .. } => { + let typed = this.value.read(cx).value().trim().is_empty(); + if !typed { + cx.emit(SecretEvent::Commit); + } + if matches!(event, InputEvent::PressEnter { .. }) { + cx.emit(SecretEvent::Submit); + } + } + _ => {} + }, + ), + cx.subscribe_in(&reference, window, |this, _, event: &InputEvent, _, cx| { + if matches!(event, InputEvent::Blur | InputEvent::PressEnter { .. }) { + this.commit_reference(cx); + } + if matches!(event, InputEvent::PressEnter { .. }) { + cx.emit(SecretEvent::Submit); + } + }), + ]; + Self { + id: id.into(), + sources: Vec::new(), + provider: None, + committed: None, + configured: false, + rejected: None, + value, + reference, + menu: None, + trigger: cx.focus_handle().tab_stop(true), + check: Check::Idle, + can_check: true, + disabled: false, + _subscriptions: subscriptions, + } + } + + pub(in crate::ui::settings) fn set_sources( + &mut self, + sources: &[SecretSource], + cx: &mut Context, + ) { + if self.sources != sources { + self.sources = sources.to_vec(); + cx.notify(); + } + } + + pub(in crate::ui::settings) fn set_disabled(&mut self, disabled: bool, cx: &mut Context) { + if self.disabled != disabled { + self.disabled = disabled; + cx.notify(); + } + } + + /// Whether the server lets this client check a secret (`checkSecretSource.permissionAtom`). + pub(in crate::ui::settings) fn set_can_check( + &mut self, + can_check: bool, + cx: &mut Context, + ) { + if self.can_check != can_check { + self.can_check = can_check; + cx.notify(); + } + } + + pub(in crate::ui::settings) fn set_check(&mut self, check: Check, cx: &mut Context) { + self.check = check; + cx.notify(); + } + + /// Shows what the server stores: a reference, a stored literal or nothing, and drops the draft. + pub(in crate::ui::settings) fn load( + &mut self, + saved: &Value, + window: &mut Window, + cx: &mut Context, + ) { + self.committed = secret::reference_of(saved); + self.configured = self.committed.is_none() && secret::is_set(saved); + self.provider = self + .committed + .as_ref() + .map(|(provider, _)| provider.clone()); + self.rejected = None; + self.check = Check::Idle; + self.menu = None; + self.sync_inputs(window, cx); + cx.notify(); + } + + /// Empties the field, as a cleared token. + pub(in crate::ui::settings) fn clear(&mut self, window: &mut Window, cx: &mut Context) { + self.load(&Value::Null, window, cx); + } + + /// What a save sends: the typed value, or the committed reference; `None` while empty or while + /// the chosen source has no valid reference yet. + pub(in crate::ui::settings) fn submission(&self, cx: &App) -> Option { + match &self.provider { + None => { + let typed = self.value.read(cx).value().trim().to_owned(); + (!typed.is_empty()).then(|| Value::String(typed)) + } + Some(provider) => self + .committed + .as_ref() + .filter(|(committed, _)| committed == provider) + .map(|(provider, reference)| secret::reference_value(provider, reference)), + } + } + + /// Whether a replacement is waiting: anything typed, or a committed reference. + pub(in crate::ui::settings) fn has_draft(&self, cx: &App) -> bool { + self.submission(cx).is_some() + } + + /// Closes the source menu; returns whether there was one, so Escape stops there. + pub(in crate::ui::settings) fn close_menu(&mut self, cx: &mut Context) -> bool { + let open = self.menu.take().is_some(); + if open { + cx.notify(); + } + open + } + + fn sync_inputs(&mut self, window: &mut Window, cx: &mut Context) { + let placeholder = if self.configured { + "Stored secret, enter a new value to replace" + } else { + "Not set" + }; + let text = self + .committed + .as_ref() + .filter(|(committed, _)| Some(committed) == self.provider.as_ref()) + .map(|(_, reference)| reference.clone()) + .unwrap_or_default(); + let placeholder_reference = self + .source_kind() + .map(secret::placeholder) + .unwrap_or_default(); + self.value.update(cx, |state, cx| { + state.set_value("", window, cx); + state.set_placeholder(placeholder, window, cx); + }); + self.reference.update(cx, |state, cx| { + state.set_value(text, window, cx); + state.set_placeholder(placeholder_reference, window, cx); + }); + } + + fn source_kind(&self) -> Option<&str> { + let provider = self.provider.as_deref()?; + self.sources + .iter() + .find(|source| source.id == provider) + .map(|source| source.kind.as_str()) + } + + fn commit_reference(&mut self, cx: &mut Context) { + let Some(provider) = self.provider.clone() else { + return; + }; + let text = self.reference.read(cx).value().to_string(); + if text.trim().is_empty() { + self.rejected = None; + if self.committed.take().is_some() { + self.check = Check::Idle; + cx.emit(SecretEvent::Changed); + cx.emit(SecretEvent::Commit); + } + cx.notify(); + return; + } + let kind = self.source_kind().unwrap_or_default().to_owned(); + if !secret::is_reference(&kind, &text) { + self.rejected = Some(text); + cx.notify(); + return; + } + self.rejected = None; + if self.committed.as_ref() != Some(&(provider.clone(), text.clone())) { + self.committed = Some((provider, text)); + self.check = Check::Idle; + cx.emit(SecretEvent::Changed); + cx.emit(SecretEvent::Commit); + } + cx.notify(); + } + + /// Picks Value (`None`) or a source (`onValueChange`). + fn pick(&mut self, next: Option, window: &mut Window, cx: &mut Context) { + self.menu = None; + self.rejected = None; + if next.is_none() { + self.committed = None; + } + self.provider = next; + self.check = Check::Idle; + self.sync_inputs(window, cx); + cx.emit(SecretEvent::Changed); + cx.notify(); + } + + fn open_menu(&mut self, cx: &mut Context) { + let at = self + .provider + .as_ref() + .and_then(|p| self.sources.iter().position(|s| &s.id == p)) + .map_or(0, |index| index + 1); + self.menu = Some(at); + cx.notify(); + } + + fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { + let count = self.sources.len() + 1; + match (event.keystroke.key.as_str(), self.menu) { + ("enter" | "space" | "down" | "up", None) => { + cx.stop_propagation(); + self.open_menu(cx); + } + ("down", Some(at)) => { + cx.stop_propagation(); + self.menu = Some((at + 1) % count); + cx.notify(); + } + ("up", Some(at)) => { + cx.stop_propagation(); + self.menu = Some((at + count - 1) % count); + cx.notify(); + } + ("enter", Some(at)) => { + cx.stop_propagation(); + let next = at.checked_sub(1).map(|i| self.sources[i].id.clone()); + self.pick(next, window, cx); + } + _ => {} + } + } + + fn icon(kind: Option<&str>, color: gpui::Hsla) -> AnyElement { + match kind { + None => Icon::new(Icons::Type) + .size(px(14.)) + .text_color(color) + .into_any_element(), + Some(kind) => match crate::assets::secret_provider_icon(kind) { + Some(path) => gpui::svg() + .path(path) + .size(px(14.)) + .flex_shrink_0() + .text_color(color) + .into_any_element(), + None => Icon::new(Icons::KeyRound) + .size(px(14.)) + .text_color(color) + .into_any_element(), + }, + } + } + + fn select(&self, cx: &mut Context) -> AnyElement { + let theme = Tokens::of(cx); + let view = cx.entity(); + let selected = self.provider.as_ref().map(|p| { + self.sources + .iter() + .find(|s| &s.id == p) + .map(|s| s.kind.clone()) + }); + let kind = match &selected { + None => None, + Some(Some(kind)) => Some(kind.as_str()), + Some(None) => Some(""), + }; + let title = match &self.provider { + None => "Value".to_owned(), + Some(p) => self + .sources + .iter() + .find(|s| &s.id == p) + .map_or_else(|| p.clone(), |s| s.label.clone()), + }; + let on_key = { + let view = view.clone(); + move |event: &KeyDownEvent, window: &mut Window, cx: &mut App| { + view.update(cx, |this, cx| this.key_down(event, window, cx)) + } + }; + let background = if theme.dark { + theme.input.opacity(0.32) + } else { + theme.background + }; + let trigger = h_flex() + .id(SharedString::from(format!("{}-source", self.id))) + .track_focus(&self.trigger) + .min_h(px(28.)) + .items_center() + .gap(px(6.)) + .px(px(9.)) + .rounded(px(8.)) + .border_1() + .border_color(theme.input) + .bg(background) + .cursor_pointer() + .on_key_down(move |event, window, cx| on_key(event, window, cx)) + .tooltip(tooltip(title)) + .child(Self::icon(kind, theme.foreground)) + .child( + div() + .mr(px(-4.)) + .opacity(0.7) + .text_color(theme.muted_foreground) + .child(Icon::new(Icons::ChevronDown).size(px(14.))), + ); + if self.disabled { + return div().opacity(0.64).child(trigger).into_any_element(); + } + let sources = self.sources.clone(); + let at = self.menu; + let chosen = self.provider.clone(); + let open = self.menu.is_some(); + let id = self.id.clone(); + Popover::new(SharedString::from(format!("{}-source-menu", self.id))) + .anchor(Anchor::TopLeft) + .offset(px(4.)) + .open(open) + .p_0() + .appearance(false) + .on_open_change({ + let view = view.clone(); + move |opened, _, cx| { + view.update(cx, |this, cx| { + if *opened { + this.open_menu(cx); + } else { + this.close_menu(cx); + } + }) + } + }) + .trigger(PopTrigger::new(trigger)) + .content(move |_, _, cx| { + let theme = Tokens::of(cx); + let mut rows: Vec<(Option, Option, String)> = + vec![(None, None, "Value".to_owned())]; + rows.extend( + sources + .iter() + .map(|s| (Some(s.id.clone()), Some(s.kind.clone()), s.label.clone())), + ); + if let Some(unknown) = chosen + .as_ref() + .filter(|p| !sources.iter().any(|s| &s.id == *p)) + { + rows.push((Some(unknown.clone()), Some(String::new()), unknown.clone())); + } + super::components::popup_shell(180., cx).children(rows.into_iter().enumerate().map( + |(index, (provider, kind, label))| { + let view = view.clone(); + let chosen_row = provider == chosen; + div() + .id(SharedString::from(format!("{id}-source-{index}"))) + .min_h(px(28.)) + .flex() + .items_center() + .gap(px(6.)) + .px_2() + .py_1() + .rounded(px(4.)) + .text_sm() + .cursor_pointer() + .when(chosen_row, |item| item.bg(theme.foreground.opacity(0.08))) + .when(at == Some(index), |item| { + item.bg(theme.accent).text_color(theme.accent_foreground) + }) + .on_click(move |_, window, cx| { + let provider = provider.clone(); + view.update(cx, |this, cx| this.pick(provider, window, cx)) + }) + .child(Self::icon(kind.as_deref(), theme.foreground)) + .child(div().min_w_0().truncate().child(label)) + }, + )) + }) + .into_any_element() + } + + fn check_button(&self, cx: &mut Context) -> Option { + let (provider, reference) = self.committed.clone()?; + if self.provider.as_ref() != Some(&provider) { + return None; + } + let theme = Tokens::of(cx); + let view = cx.entity(); + let enabled = self.can_check && self.check != Check::Checking; + let tip = if !self.can_check { + "You cannot check secrets on this environment".to_owned() + } else { + match &self.check { + Check::Idle => "Check that the secret resolves".to_owned(), + Check::Checking => "Checking the secret".to_owned(), + Check::Resolved => "Secret resolved".to_owned(), + Check::Failed(detail) => detail.clone(), + } + }; + let glyph: AnyElement = match &self.check { + Check::Checking => spinner(12., theme.muted_foreground), + Check::Resolved => Icon::new(Icons::Check) + .size(px(12.)) + .text_color(gpui::rgb(0x10b981)) + .into_any_element(), + Check::Failed(_) => Icon::new(Icons::TriangleAlert) + .size(px(12.)) + .text_color(theme.destructive) + .into_any_element(), + Check::Idle => Icon::new(Icons::RefreshCw) + .size(px(12.)) + .text_color(theme.muted_foreground) + .into_any_element(), + }; + let button = icon_button( + SharedString::from(format!("{}-check", self.id)), + Variant::GhostMuted, + enabled, + cx, + ) + .size(px(20.)) + .tooltip(tooltip(tip)) + .child(glyph); + Some(if enabled { + button + .on_click(move |_, _, cx| { + let (provider, reference) = (provider.clone(), reference.clone()); + view.update(cx, |this, cx| { + this.check = Check::Checking; + cx.emit(SecretEvent::Check(provider, reference)); + cx.notify(); + }) + }) + .into_any_element() + } else { + button.into_any_element() + }) + } +} + +impl Render for SecretField { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let theme = Tokens::of(cx); + let show_source = !(self.sources.is_empty() && self.provider.is_none()); + let value = match &self.provider { + None => { + sm_field(&self.value, false, false, self.disabled, window, cx).into_any_element() + } + Some(_) => { + let check = self.check_button(cx); + let message = self + .rejected + .is_some() + .then(|| secret::invalid_message(self.source_kind().unwrap_or_default())); + v_flex() + .min_w_0() + .flex_1() + .gap(px(4.)) + .child( + h_flex() + .min_w_0() + .items_center() + .gap(px(6.)) + .child(div().flex_1().min_w_0().child(sm_field( + &self.reference, + true, + self.rejected.is_some(), + self.disabled, + window, + cx, + ))) + .children(check), + ) + .children(message.map(|text| { + div() + .text_size(px(11.)) + .line_height(px(14.)) + .text_color(theme.destructive) + .child(text) + })) + .into_any_element() + } + }; + h_flex() + .min_w_0() + .flex_1() + .items_start() + .gap(px(6.)) + .when(show_source, |row| row.child(self.select(cx))) + .child(div().min_w_0().flex_1().child(value)) + } +} diff --git a/apps/desktop-gpui/src/ui/settings/source_control_panel.rs b/apps/desktop-gpui/src/ui/settings/source_control_panel.rs new file mode 100644 index 000000000000..f300fb2ae2e9 --- /dev/null +++ b/apps/desktop-gpui/src/ui/settings/source_control_panel.rs @@ -0,0 +1,1480 @@ +//! The Source control tab: the Repositories rows of `ProjectDefaultsSettings` (440-535), then +//! `SourceControlSettingsPanel` (SourceControlSettings.tsx:337-527) and +//! `SourceControlWritingSettingsSection` (SourceControlWritingSettings.tsx:88-615). The panel owns +//! its menus, text areas and requests; server-settings writes are optimistic and put back when +//! the call fails. + +use super::components::{ + HoverHandler, KeyHandler, Menu, PickHandler, SelectTrigger, Toggle, disclosure, group, + reset_button_labeled, section, section_with_action, select_menu, switch, +}; +use super::devices_panel::widgets::{Variant, button, icon_button, note, spinner, warning}; +use super::scope_inheritance::{Marker, marker_row_with}; +use super::scope_pickers::ScopeBar; +use super::tokens::Tokens; +use super::*; +use crate::connection::{Action, ActionSender, Call}; +use crate::settings_labs::{Scope, resolve}; +use crate::settings_source_control::{ + self as sc, Badge, Discovery, Dot, Field, Item, MERGE_METHODS, STYLE_MODES, + SourceControlConfig, Span, +}; +use crate::status::CallEvent; +use gpui::{FocusHandle, KeyDownEvent, Subscription}; +use gpui_component::input::{InputEvent, Textarea, TextareaState}; +use serde_json::{Value, json}; +use std::collections::{HashMap, HashSet}; +use std::rc::Rc; + +mod hosts; + +/// `emerald-500` and `amber-500`, the `--success` and `--warning` tokens (index.css:1162-1165). +const SUCCESS: u32 = 0x10b981; +const WARNING: u32 = 0xf59e0b; +/// `max-w-2xl`. +const TEXT_AREA_MAX: f32 = 672.; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Pick { + Merge, + Style, +} + +impl Pick { + fn index(self) -> usize { + match self { + Self::Merge => 0, + Self::Style => 1, + } + } + + fn count(self) -> usize { + match self { + Self::Merge => MERGE_METHODS.len(), + Self::Style => STYLE_MODES.len(), + } + } +} + +pub struct SourceControlPanel { + actions: ActionSender, + scope: Entity, + wide: bool, + config: SourceControlConfig, + projects: crate::settings_devices::ProjectIds, + discovery: Option, + scan_error: Option, + scan_token: Option, + requested: bool, + /// Settings writes in flight, with the config to fall back to. + pending: HashMap, + next_token: u64, + menu: Option<(Pick, usize)>, + triggers: [FocusHandle; 2], + advanced_open: bool, + /// Providers whose account name is showing. + revealed: HashSet, + custom: Entity, + review: Entity, + /// Providers whose details are open. + expanded: HashSet, + hosts: hosts::HostForms, + /// Host writes in flight (also keyed in `pending`). + host_writes: HashSet, + /// Secret checks in flight, with the field that asked. + checks: HashMap>, + /// Providers whose login names show in the details. + reveal_accounts: HashSet, + /// The open account select: host and highlighted row. + account_menu: Option<(String, usize)>, + _subscriptions: Vec, +} + +/// What the scope bar says about the environment the tab inspects. +struct Ctx { + in_project: bool, + members: Vec, + label: String, + machine: crate::projects::MachineKind, + connected: bool, + /// `scope.environmentIds.length !== 1 && connectedEnvironments.length > 1`. + aggregate: bool, +} + +impl SourceControlPanel { + pub fn new( + actions: ActionSender, + scope: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let custom = cx.new(|cx| { + TextareaState::new(window, cx) + .placeholder("Keep titles concise. Use short bullet points in descriptions.") + .auto_grow(4, 4) + }); + let review = cx.new(|cx| { + TextareaState::new(window, cx) + .placeholder( + "Show service interfaces before implementations. Skip test files unless they change behavior. Keep summaries to two sentences.", + ) + .auto_grow(4, 4) + }); + let hosts = hosts::HostForms::new(window, cx); + let mut subscriptions = vec![ + cx.observe(&scope, |_, _, cx| cx.notify()), + cx.subscribe_in(&custom, window, |this, _, event: &InputEvent, _, cx| { + if matches!(event, InputEvent::Blur) { + this.commit(Field::StyleInstructions, cx); + } + }), + cx.subscribe_in(&review, window, |this, _, event: &InputEvent, _, cx| { + if matches!(event, InputEvent::Blur) { + this.commit(Field::ReviewInstructions, cx); + } + }), + ]; + subscriptions.extend(hosts.subscribe(window, cx)); + Self { + actions, + scope, + wide: true, + config: SourceControlConfig::default(), + projects: crate::settings_devices::ProjectIds::new(), + discovery: None, + scan_error: None, + scan_token: None, + requested: false, + pending: HashMap::new(), + next_token: sc::TOKEN_BASE, + menu: None, + triggers: std::array::from_fn(|_| cx.focus_handle().tab_stop(true)), + advanced_open: false, + revealed: HashSet::new(), + custom, + review, + expanded: HashSet::new(), + hosts, + host_writes: HashSet::new(), + checks: HashMap::new(), + reveal_accounts: HashSet::new(), + account_menu: None, + _subscriptions: subscriptions, + } + } + + pub fn set_wide(&mut self, wide: bool, cx: &mut Context) { + if self.wide != wide { + self.wide = wide; + cx.notify(); + } + } + + /// The attached environment's settings and the projects that hold the overrides. + pub fn set_data( + &mut self, + config: SourceControlConfig, + projects: crate::settings_devices::ProjectIds, + cx: &mut Context, + ) { + let mut changed = self.projects != projects; + self.projects = projects; + if self.pending.is_empty() && self.config != config { + self.config = config; + changed = true; + } + self.ensure_scan(cx); + if changed { + cx.notify(); + } + } + + /// Closes menus and scans again, as the settings page opens. + pub fn reload(&mut self, cx: &mut Context) { + self.menu = None; + self.advanced_open = false; + self.revealed.clear(); + self.expanded.clear(); + self.reveal_accounts.clear(); + self.account_menu = None; + self.requested = false; + self.ensure_scan(cx); + cx.notify(); + } + + /// Closes an open menu; returns whether there was one, so Escape stops there. + pub fn close_menu(&mut self, window: &mut Window, cx: &mut Context) -> bool { + if self.account_menu.take().is_some() { + cx.notify(); + return true; + } + let mut closed = false; + for (_, _, field) in self.hosts.secrets() { + closed |= field.update(cx, |field, cx| field.close_menu(cx)); + } + if closed { + return true; + } + let Some((pick, _)) = self.menu.take() else { + return false; + }; + window.focus(&self.triggers[pick.index()], cx); + cx.notify(); + true + } + + /// The first scan of an open page, once an environment is connected. + fn ensure_scan(&mut self, cx: &mut Context) { + if !self.requested && self.ctx(cx).connected { + self.requested = true; + self.scan(cx); + } + } + + fn scan(&mut self, cx: &mut Context) { + if self.scan_token.is_some() { + return; + } + let token = self.next_token; + self.next_token += 1; + let action = Action::Call { + token, + call: Call::DiscoverSourceControl, + payload: json!({}), + }; + if self.actions.try_send(action).is_err() { + return; + } + self.scan_token = Some(token); + self.scan_error = None; + cx.notify(); + } + + /// The outcome of a scan or of a write. + pub fn on_call(&mut self, token: u64, event: CallEvent, cx: &mut Context) { + if self.checks.contains_key(&token) { + self.checked(token, event, cx); + return; + } + if matches!(event, CallEvent::Progress(_)) { + return; + } + if self.scan_token == Some(token) { + self.scan_token = None; + match event { + CallEvent::Done(value) => { + self.discovery = Some(Discovery::parse(&value)); + self.scan_error = None; + } + CallEvent::Failed(message) => self.scan_error = Some(message), + CallEvent::Progress(_) => {} + } + cx.notify(); + return; + } + let Some(previous) = self.pending.remove(&token) else { + return; + }; + if matches!(event, CallEvent::Failed(_)) { + self.host_writes.remove(&token); + self.config = previous; + cx.notify(); + } else { + self.host_saved(token, cx); + cx.notify(); + } + } + + fn ctx(&self, cx: &App) -> Ctx { + let scope = self.scope.read(cx); + let (search, data) = (scope.search(), scope.data()); + let resolved = resolve(search, data); + let selected = match &resolved { + Scope::Environment(id) => data.environments.iter().find(|e| &e.id == id), + _ => None, + }; + let environment = selected + .or_else(|| data.environments.iter().find(|e| e.connected && e.writable)) + .or_else(|| data.environments.iter().find(|e| e.connected)) + .or(data.environments.first()); + let connected = data.environments.iter().filter(|e| e.connected).count(); + Ctx { + in_project: matches!(resolved, Scope::Project(_)), + members: search + .project + .as_ref() + .and_then(|key| self.projects.get(key)) + .cloned() + .unwrap_or_default(), + label: environment.map_or_else(|| "Environment".to_owned(), |e| e.label.clone()), + machine: environment.map_or(crate::projects::MachineKind::Desktop, |e| e.machine), + connected: environment.is_some_and(|e| e.connected), + aggregate: !matches!(resolved, Scope::Environment(_)) && connected > 1, + } + } + + /// Why a control is inert, or `None` when its write reaches the environment. + fn gate(&self, field: Field, cx: &App) -> Option { + let scope = self.scope.read(cx); + sc::reach(field, scope.search(), scope.data()).err() + } + + fn value(&self, field: Field, ctx: &Ctx) -> Value { + self.config.effective(field, &ctx.members, ctx.in_project) + } + + fn text(&self, field: Field, ctx: &Ctx) -> String { + self.value(field, ctx) + .as_str() + .unwrap_or_default() + .to_owned() + } + + /// A project scope writes the entry of every project of the group; anywhere else the + /// environment's value is written (`None` removes an override, or restores the default). + fn write(&mut self, changes: Vec<(Field, Option)>, cx: &mut Context) { + let ctx = self.ctx(cx); + if changes + .iter() + .any(|(field, _)| self.gate(*field, cx).is_some()) + || (ctx.in_project && ctx.members.is_empty()) + { + return; + } + let members = ctx.in_project.then_some(ctx.members.as_slice()); + let patch = self.config.patch(&changes, members); + let previous = self.config.clone(); + self.config.apply(&changes, members); + let token = self.next_token; + self.next_token += 1; + let action = Action::Call { + token, + call: Call::UpdateSettings, + payload: patch, + }; + if self.actions.try_send(action).is_ok() { + self.pending.insert(token, previous); + } else { + self.config = previous; + } + cx.notify(); + } + + /// Saves a text area's trimmed content when it lost focus and differs from the setting. + fn commit(&mut self, field: Field, cx: &mut Context) { + let input = if field == Field::StyleInstructions { + &self.custom + } else { + &self.review + }; + let value = input.read(cx).value().trim().to_owned(); + let ctx = self.ctx(cx); + if value != self.text(field, &ctx) { + self.write(vec![(field, Some(json!(value)))], cx); + } + } + + /// Puts the setting's text into an unfocused text area, as the web's `key` remount does. + fn sync_inputs(&self, ctx: &Ctx, window: &mut Window, cx: &mut Context) { + for (input, field) in [ + (&self.custom, Field::StyleInstructions), + (&self.review, Field::ReviewInstructions), + ] { + let want = self.text(field, ctx); + let focused = input.read(cx).focus_handle(cx).is_focused(window); + if !focused && input.read(cx).value().as_ref() != want.as_str() { + input.update(cx, |state, cx| state.set_value(want, window, cx)); + } + } + } + + fn open_menu(&mut self, pick: Pick, selected: usize, cx: &mut Context) { + self.menu = Some((pick, selected)); + cx.notify(); + } + + fn pick(&mut self, pick: Pick, index: usize, window: &mut Window, cx: &mut Context) { + match pick { + Pick::Merge => self.write(vec![(Field::MergeMethod, Some(sc::merge_value(index)))], cx), + Pick::Style => { + let ctx = self.ctx(cx); + let mut changes = vec![(Field::StyleMode, Some(json!(STYLE_MODES[index].0)))]; + if self.text(Field::StyleMode, &ctx) == "custom" { + let text = self.custom.read(cx).value().trim().to_owned(); + changes.push((Field::StyleInstructions, Some(json!(text)))); + } + self.write(changes, cx); + } + } + self.close_menu(window, cx); + } + + fn key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context) { + let Some((pick, highlighted)) = self.menu else { + return; + }; + let count = pick.count(); + let next = match event.keystroke.key.as_str() { + "down" => (highlighted + 1) % count, + "up" => (highlighted + count - 1) % count, + "home" => 0, + "end" => count - 1, + "enter" | "space" => { + self.pick(pick, highlighted, window, cx); + cx.stop_propagation(); + return; + } + _ => return, + }; + self.menu = Some((pick, next)); + cx.stop_propagation(); + cx.notify(); + } + + /// A control that ignores the pointer, half transparent under the reason as its tooltip. + fn inert(id: &'static str, why: Option, control: AnyElement) -> AnyElement { + match why { + None => control, + Some(why) => div() + .id((id, 1usize)) + .opacity(0.5) + .tooltip(tooltip(why)) + .child(control) + .into_any_element(), + } + } + + fn marker(&self, id: &'static str, field: Field, ctx: &Ctx) -> Marker { + let project = self.config.project_override(field, &ctx.members); + let (state, summary) = sc::inheritance( + field, + ctx.in_project, + project.is_some(), + self.config.environment_set(field), + &ctx.label, + ); + Marker { + id, + state, + summary, + layers: sc::layers( + field, + &ctx.label, + ctx.in_project, + project.as_ref(), + &self.config, + ), + machine: ctx.machine, + environment: ctx.label.clone(), + groups: Vec::new(), + } + } + + /// `SettingResetButton`: shown while any of the fields holds a value of its own. + fn reset( + &self, + id: &'static str, + tooltip: &'static str, + fields: &[Field], + ctx: &Ctx, + window: &mut Window, + cx: &mut Context, + ) -> Option { + let dirty = fields.iter().any(|field| { + if ctx.in_project { + field.project_scoped() + && self.config.project_override(*field, &ctx.members).is_some() + } else { + self.config.environment_set(*field) + } + }); + if !dirty || self.gate(fields[0], cx).is_some() { + return None; + } + let view = cx.entity(); + let changes: Vec<(Field, Option)> = + fields.iter().map(|field| (*field, None)).collect(); + Some(reset_button_labeled( + id, + tooltip, + move |_, _, cx| { + let changes = changes.clone(); + view.update(cx, |this, cx| this.write(changes, cx)) + }, + window, + cx, + )) + } + + fn flag( + &self, + id: &'static str, + field: Field, + ctx: &Ctx, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let checked = self.value(field, ctx).as_bool().unwrap_or(false); + let why = self.gate(field, cx); + let view = cx.entity(); + let toggle: Toggle = if why.is_some() { + Rc::new(|_, _| {}) + } else { + Rc::new(move |_, cx| { + view.update(cx, |this, cx| { + this.write(vec![(field, Some(json!(!checked)))], cx) + }) + }) + }; + Self::inert(id, why, switch(id, checked, toggle, window, cx)) + } + + #[allow(clippy::too_many_arguments)] + fn setting( + &self, + id: &'static str, + title: &'static str, + description: impl IntoElement, + field: Field, + reset: Option, + control: AnyElement, + preview: Option, + ctx: &Ctx, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + marker_row_with( + id, + title, + description, + Some(self.marker(marker_id(id), field, ctx)), + reset, + control, + preview, + self.wide, + window, + cx, + ) + .into_any_element() + } + + /// `Select` (select.tsx) over a `Popover`, as the Labs tab builds it. + fn select( + &self, + pick: Pick, + selected: usize, + width: f32, + why: Option, + cx: &mut Context, + ) -> AnyElement { + let (id, popover, labels): (&'static str, &'static str, Vec<&'static str>) = match pick { + Pick::Merge => ( + "settings-merge-method", + "settings-merge-method-select", + MERGE_METHODS.iter().map(|(_, label)| *label).collect(), + ), + Pick::Style => ( + "settings-source-control-writing-style", + "settings-source-control-writing-style-select", + STYLE_MODES.iter().map(|(_, label)| *label).collect(), + ), + }; + let view = cx.entity(); + let on_key: KeyHandler = { + let view = view.clone(); + Rc::new(move |event, _, cx| { + if matches!( + event.keystroke.key.as_str(), + "enter" | "space" | "down" | "up" + ) { + cx.stop_propagation(); + view.update(cx, |this, cx| this.open_menu(pick, selected, cx)); + } + }) + }; + let trigger = SelectTrigger::new( + id, + labels[selected], + width, + self.triggers[pick.index()].clone(), + on_key, + ); + if why.is_some() { + return Self::inert(id, why, trigger.into_any_element()); + } + let open = self.menu.is_some_and(|(current, _)| current == pick); + let highlighted = self + .menu + .filter(|(current, _)| *current == pick) + .map_or(usize::MAX, |(_, index)| index); + let on_pick: PickHandler = { + let view = view.clone(); + Rc::new(move |index, window, cx| { + view.update(cx, |this, cx| this.pick(pick, index, window, cx)) + }) + }; + let on_hover: HoverHandler = { + let view = view.clone(); + Rc::new(move |index, cx| { + view.update(cx, |this, cx| { + if this + .menu + .is_some_and(|(p, current)| p == pick && current != index) + { + this.menu = Some((pick, index)); + cx.notify(); + } + }) + }) + }; + Popover::new(popover) + .anchor(Anchor::TopRight) + .offset(px(4.)) + .open(open) + .p_0() + .appearance(false) + .on_open_change(move |opened, window, cx| { + view.update(cx, |this, cx| { + if *opened { + this.open_menu(pick, selected, cx); + } else if this.menu.is_some() { + this.close_menu(window, cx); + } + }) + }) + .trigger(trigger) + .content(move |_, _, cx| { + select_menu( + &Menu { + id, + labels: labels.clone(), + selected, + highlighted, + width, + on_pick: on_pick.clone(), + on_hover: on_hover.clone(), + }, + cx, + ) + }) + .into_any_element() + } + + /// `Textarea` (textarea.tsx) under a setting: `mt-3 max-w-2xl pb-3.5`. + fn text_area( + &self, + input: &Entity, + label: &'static str, + disabled: bool, + window: &Window, + cx: &App, + ) -> AnyElement { + let theme = Tokens::of(cx); + let focused = input.read(cx).focus_handle(cx).contains_focused(window, cx); + let background = if theme.dark { + theme.input.opacity(0.32) + } else { + theme.background + }; + div() + .mt_3() + .pb(px(14.)) + .max_w(px(TEXT_AREA_MAX)) + .child( + div() + .w_full() + .rounded(px(8.)) + .border_1() + .border_color(if focused { theme.ring } else { theme.input }) + .bg(background) + .text_size(px(14.)) + .when(disabled, |frame| frame.opacity(0.64)) + .child( + Textarea::new(input) + .appearance(false) + .bordered(false) + .disabled(disabled) + .aria_label(label), + ), + ) + .into_any_element() + } + + fn repositories(&self, ctx: &Ctx, window: &mut Window, cx: &mut Context) -> gpui::Div { + let project = ctx.in_project; + let merge_why = self.gate(Field::MergeMethod, cx); + let merge = sc::merge_index(&self.value(Field::MergeMethod, ctx)); + let rows = vec![ + self.setting( + "automatic-pull", + "Automatically pull", + if project { + "Keeps this project's default branch current when the checkout has no local changes or commits." + } else { + "Keeps the default branch current when the checkout has no local changes or commits. Projects can override it." + }, + Field::AutoPull, + self.reset( + "settings-automatic-pull-reset", + "Reset automatic pull to off", + &[Field::AutoPull], + ctx, + window, + cx, + ), + self.flag("settings-automatic-pull-switch", Field::AutoPull, ctx, window, cx), + None, + ctx, + window, + cx, + ), + self.setting( + "remove-agent-credits-on-merge", + "Remove agent credits when merging", + "Remove recognized agent credit lines from merge and squash messages on hosts that support it, keeping human co-authors. Includes auto-merge. Excludes merge queues, stack merges, and existing commits.", + Field::AgentCredits, + self.reset( + "settings-agent-credits-reset", + "Keep agent credits", + &[Field::AgentCredits], + ctx, + window, + cx, + ), + self.flag("settings-agent-credits-switch", Field::AgentCredits, ctx, window, cx), + None, + ctx, + window, + cx, + ), + self.setting( + "pull-request-merge-method", + "Default merge method", + if project { + "Pull requests in this project start with this method." + } else { + "Pull requests start with this method. Last selected reuses whatever you chose most recently on this device." + }, + Field::MergeMethod, + self.reset( + "settings-merge-method-reset", + "Reset to last selected", + &[Field::MergeMethod], + ctx, + window, + cx, + ), + self.select(Pick::Merge, merge, 176., merge_why, cx), + None, + ctx, + window, + cx, + ), + ]; + section("Repositories", group(rows, cx)) + } + + fn scan_button(&self, cx: &mut Context) -> AnyElement { + let theme = Tokens::of(cx); + let idle = self.scan_token.is_none(); + let view = cx.entity(); + let glyph = if idle { + Icon::new(Icons::RefreshCw).size(px(14.)).into_any_element() + } else { + spinner(14., theme.muted_foreground) + }; + icon_button( + "settings-source-control-rescan", + Variant::GhostMuted, + idle, + cx, + ) + .size(px(24.)) + .tooltip(tooltip("Rescan Git and hosting integrations")) + .when(idle, |button| { + button.on_click(move |_, _, cx| view.update(cx, |this, cx| this.scan(cx))) + }) + .child(glyph) + .into_any_element() + } + + /// `SourceControlItemMark`: the icon with its status dot at its top left corner. + fn mark(&self, item: &Item, cx: &App) -> AnyElement { + let theme = Tokens::of(cx); + let color = match item.dot() { + Dot::Success => gpui::rgb(SUCCESS).into(), + Dot::Warning => gpui::rgb(WARNING).into(), + Dot::Muted => theme.muted_foreground.opacity(0.35), + }; + let dot = div().size(px(8.)).rounded_full().bg(color); + let Some(path) = crate::assets::source_control_icon(&item.kind, theme.dark) else { + return dot.flex_shrink_0().into_any_element(); + }; + div() + .relative() + .size(px(20.)) + .flex_shrink_0() + .flex() + .items_center() + .justify_center() + .child( + div() + .size(px(18.)) + .opacity(0.8) + .child(img(path).size_full()), + ) + .child( + div() + .absolute() + .left(px(-4.)) + .top(px(-4.)) + .size(px(12.)) + .rounded_full() + .bg(theme.background) + .flex() + .items_center() + .justify_center() + .child(dot), + ) + .into_any_element() + } + + /// `Badge size="sm"` (badge.tsx:21): `h-4 rounded-[.25rem] px-[3px] text-[.625rem]`. + fn badge(item: Badge, cx: &App) -> gpui::Div { + let theme = Tokens::of(cx); + let (background, color) = match item { + Badge::ComingSoon => ( + gpui::Hsla::from(gpui::rgb(WARNING)).opacity(if theme.dark { 0.16 } else { 0.08 }), + warning(&theme), + ), + Badge::NotAuthenticated if theme.dark => ( + gpui::white().opacity(0.03), + gpui::Hsla::from(gpui::rgb(0xf5f5f5)), + ), + Badge::NotAuthenticated => ( + gpui::Hsla::from(gpui::rgb(0xfafafa)), + gpui::Hsla::from(gpui::rgb(0x27272a)), + ), + }; + div() + .h(px(16.)) + .min_w(px(16.)) + .px(px(3.)) + .flex() + .flex_shrink_0() + .items_center() + .justify_center() + .rounded(px(4.)) + .bg(background) + .text_color(color) + .text_size(px(10.)) + .line_height(px(10.)) + .font_weight(FontWeight::MEDIUM) + .whitespace_nowrap() + .child(item.label()) + } + + /// One flex child of an item's summary line. + fn span(&self, index: usize, span: &Span, cx: &mut Context) -> AnyElement { + match span { + Span::Text(text) => div().child(text.clone()).into_any_element(), + Span::Code(text) => div() + .px_1() + .py(px(1.)) + .rounded(px(4.)) + .bg(Tokens::of(cx).muted) + .font_family(cx.theme().mono_font_family.clone()) + .text_size(px(11.)) + .child(text.clone()) + .into_any_element(), + Span::Account(account) => { + let shown = self.revealed.contains(&index); + let view = cx.entity(); + div() + .id(("settings-source-control-account", index)) + .cursor_pointer() + .tooltip(tooltip(if shown { + "Click to hide account" + } else { + "Click to reveal account" + })) + .on_click(move |_, _, cx| { + view.update(cx, |this, cx| { + if !this.revealed.remove(&index) { + this.revealed.insert(index); + } + cx.notify(); + }) + }) + .child(hosts::redacted_login( + &if shown { + account.clone() + } else { + sc::redacted(account) + }, + true, + cx, + )) + .into_any_element() + } + } + } + + /// `DiscoveryItemRow` (SourceControlSettings.tsx:208-299). + fn item_row( + &self, + index: usize, + position: (usize, usize), + item: &Item, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let theme = Tokens::of(cx); + let hover = theme.muted.opacity(0.2); + let mono = cx.theme().mono_font_family.clone(); + let title = h_flex() + .min_w_0() + .flex_wrap() + .items_center() + .gap_2() + .child(self.mark(item, cx)) + .child( + div() + .truncate() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(theme.foreground) + .child(item.label.clone()), + ) + .children(item.version.clone().map(|version| { + div() + .font_family(mono) + .text_xs() + .text_color(theme.muted_foreground) + .child(version) + })) + .children(item.badge().map(|badge| Self::badge(badge, cx))); + let summary = h_flex() + .min_w_0() + .flex_wrap() + .items_center() + .gap_x(px(4.)) + .text_xs() + .line_height(px(18.)) + .text_color(theme.muted_foreground.opacity(0.8)) + .children(item.summary().iter().map(|span| self.span(index, span, cx))); + let enabled = switch( + ELEMENT_SWITCH[index.min(ELEMENT_SWITCH.len() - 1)], + item.enabled(), + Rc::new(|_, _| {}), + window, + cx, + ); + div() + .id(("settings-source-control-item", index)) + .when(position.0 == 0, |row| row.rounded_t(px(12.))) + .when(position.0 + 1 == position.1, |row| row.rounded_b(px(12.))) + .when(item.not_ready(), |row| row.opacity(0.8)) + .hover(move |style| style.bg(hover)) + .px_4() + .py_3() + .child( + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + v_flex() + .min_w_0() + .flex_1() + .gap_1() + .child(title) + .child(summary), + ) + .child( + h_flex() + .flex_shrink_0() + .items_center() + .gap_2() + .children(self.details_toggle(index, item, cx)) + .children( + (!item.not_ready()) + .then(|| div().opacity(0.64).child(enabled).into_any_element()), + ), + ), + ) + .children(self.host_details(item, window, cx)) + .into_any_element() + } + + fn skeleton(&self, title: String, action: Option, cx: &App) -> gpui::Div { + let theme = Tokens::of(cx); + let bar = |width: f32, height: f32| { + div() + .w(px(width)) + .h(px(height)) + .rounded_full() + .bg(theme.foreground.opacity(0.06)) + }; + let rows = ["primary", "secondary"] + .iter() + .map(|_| { + h_flex() + .items_center() + .justify_between() + .px_4() + .py_3() + .child( + v_flex() + .gap_2() + .child( + h_flex() + .items_center() + .gap_2() + .child( + div() + .size(px(18.)) + .rounded(px(4.)) + .bg(theme.foreground.opacity(0.06)), + ) + .child(bar(112., 16.)) + .child(bar(56., 20.)), + ) + .child(bar(240., 12.)), + ) + .child( + h_flex() + .items_center() + .gap_2() + .child( + div() + .size(px(28.)) + .rounded(px(4.)) + .bg(theme.foreground.opacity(0.06)), + ) + .child(bar(36., 20.)), + ) + .into_any_element() + }) + .collect(); + section_with_action(title, action, group(rows, cx)) + } + + /// The empty card of a server with nothing detected, or whose scan failed. + fn empty(&self, cx: &mut Context) -> gpui::Div { + let theme = Tokens::of(cx); + let failed = self.scan_error.is_some(); + let view = cx.entity(); + let idle = self.scan_token.is_none(); + let scan = button("settings-source-control-scan", Variant::Outline, idle, cx) + .when(idle, |scan| { + scan.on_click(move |_, _, cx| view.update(cx, |this, cx| this.scan(cx))) + }) + .child(if idle { + Icon::new(Icons::RefreshCw).size(px(14.)).into_any_element() + } else { + spinner(14., theme.foreground) + }) + .child("Scan"); + let card = v_flex() + .items_center() + .gap_4() + .px_6() + .py_8() + .text_center() + .child( + div() + .size(px(40.)) + .flex() + .items_center() + .justify_center() + .rounded(px(8.)) + .border_1() + .border_color(theme.border) + .bg(theme.card) + .text_color(theme.muted_foreground) + .child(Icon::new(Icons::GitBranch).size(px(20.))), + ) + .child( + v_flex() + .items_center() + .gap_1() + .child( + div() + .text_base() + .font_weight(FontWeight::MEDIUM) + .child(if failed { + "Could not scan the server environment" + } else { + "Nothing detected yet" + }), + ) + .child( + div() + .max_w(px(384.)) + .text_sm() + .text_color(theme.muted_foreground) + .child(self.scan_error.clone().unwrap_or_else(|| { + "Install Git on the server, add optional hosting integrations or credentials your workspace needs, then rescan." + .to_owned() + })), + ), + ) + .child(scan); + section( + "Server environment", + group(vec![card.into_any_element()], cx), + ) + } + + /// The Version Control and Source control providers sections, or what stands in for them. + fn discovery(&self, ctx: &Ctx, window: &mut Window, cx: &mut Context) -> Vec { + let theme = Tokens::of(cx); + if !ctx.connected { + let message = note( + "Connect an environment to inspect its version control tools and hosting integrations.", + cx, + ); + let message = div() + .px_4() + .py_3() + .text_sm() + .text_color(theme.muted_foreground) + .child(message.text_sm()); + return vec![ + section( + "Server environment", + group(vec![message.into_any_element()], cx), + ) + .into_any_element(), + ]; + } + let suffix = if ctx.aggregate { + format!(" \u{b7} {}", ctx.label) + } else { + String::new() + }; + let Some(discovery) = self.discovery.as_ref() else { + if self.scan_error.is_some() { + return vec![self.empty(cx).into_any_element()]; + } + let action = self.scan_button(cx); + return vec![ + self.skeleton(format!("Version Control{suffix}"), Some(action), cx) + .into_any_element(), + self.skeleton("Source control providers".to_owned(), None, cx) + .into_any_element(), + ]; + }; + if discovery.is_empty() { + return vec![self.empty(cx).into_any_element()]; + } + let has_vcs = !discovery.version_control.is_empty(); + let mut sections = Vec::new(); + let mut next = 0; + let mut rows = + |items: &[Item], this: &Self, window: &mut Window, cx: &mut Context| { + let rows: Vec = items + .iter() + .enumerate() + .map(|(position, item)| { + this.item_row(next + position, (position, items.len()), item, window, cx) + }) + .collect(); + next += items.len(); + group(rows, cx) + }; + if has_vcs { + let body = rows(&discovery.version_control, self, window, cx); + let action = self.scan_button(cx); + sections.push( + section_with_action(format!("Version Control{suffix}"), Some(action), body) + .into_any_element(), + ); + } + if !discovery.providers.is_empty() { + let body = rows(&discovery.providers, self, window, cx); + let (title, action) = if has_vcs { + ("Source control providers".to_owned(), None) + } else { + ( + format!("Source control providers{suffix}"), + Some(self.scan_button(cx)), + ) + }; + sections.push(section_with_action(title, action, body).into_any_element()); + } + sections + } + + /// The model rows: a text for the model in use and a switch that turns a dedicated model on. + fn model_control( + &self, + id: &'static str, + field: Field, + none: &'static str, + ctx: &Ctx, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let theme = Tokens::of(cx); + if !ctx.connected { + return div() + .text_sm() + .text_color(theme.muted_foreground) + .child(none) + .into_any_element(); + } + let current = self.value(field, ctx); + let dedicated = current.is_object(); + let base = self.config.text_generation(&ctx.members, ctx.in_project); + let why = self.gate(field, cx); + let enabled = dedicated || base.is_some(); + let view = cx.entity(); + let toggle: Toggle = if why.is_some() || !enabled { + Rc::new(|_, _| {}) + } else { + Rc::new(move |_, cx| { + let next = if dedicated { + Some(Value::Null) + } else { + base.clone() + }; + view.update(cx, |this, cx| this.write(vec![(field, next)], cx)) + }) + }; + let toggle = switch(id, dedicated, toggle, window, cx); + let toggle = if enabled { + toggle + } else { + div().opacity(0.64).child(toggle).into_any_element() + }; + h_flex() + .flex_wrap() + .items_center() + .justify_end() + .gap_2() + .children(sc::model_label(¤t).map(|model| { + div() + .max_w(px(220.)) + .truncate() + .text_sm() + .text_color(theme.muted_foreground) + .child(model) + })) + .child(Self::inert(id, why, toggle)) + .into_any_element() + } + + fn writing(&self, ctx: &Ctx, window: &mut Window, cx: &mut Context) -> gpui::Div { + let mode = self.text(Field::StyleMode, ctx); + let custom_why = self.gate(Field::StyleInstructions, cx); + let style_select = self.select( + Pick::Style, + sc::style_index(&json!(mode)), + 224., + self.gate(Field::StyleMode, cx), + cx, + ); + let custom = (mode == "custom").then(|| { + self.text_area( + &self.custom, + "Custom source control writing instructions", + custom_why.is_some(), + window, + cx, + ) + }); + let mut rows = vec![ + self.setting( + "source-control-writing-style", + "Source control writing style", + sc::style_description(&mode), + Field::StyleMode, + self.reset( + "settings-writing-style-reset", + "Reset to default", + &[Field::StyleMode, Field::StyleInstructions], + ctx, + window, + cx, + ), + style_select, + custom, + ctx, + window, + cx, + ), + self.setting( + "review-guides", + "Review guides (beta)", + "Add a Guide tab to pull requests. An agent explains the changes and points out decisions that need review.", + Field::ReviewGuides, + None, + self.flag("settings-review-guides", Field::ReviewGuides, ctx, window, cx), + None, + ctx, + window, + cx, + ), + ]; + if self.value(Field::ReviewGuides, ctx).as_bool() == Some(true) { + let model = self.model_control( + "settings-review-guide-model", + Field::ReviewModel, + "Connect an environment to choose its review guide model.", + ctx, + window, + cx, + ); + rows.push(self.setting( + "review-guide-model", + "Review guide model", + "Model that reads a pull request to write its review guide. It reads the change itself over many steps, so a stronger model helps. Off uses the source control writer model.", + Field::ReviewModel, + None, + model, + None, + ctx, + window, + cx, + )); + let area = self.text_area( + &self.review, + "Review guide instructions", + self.gate(Field::ReviewInstructions, cx).is_some(), + window, + cx, + ); + rows.push(self.setting( + "review-guide-instructions", + "Review guide instructions", + "Your preferences for review guides: what to show first, dwell on, or skip. The guide's format and coverage stay the same.", + Field::ReviewInstructions, + self.reset( + "settings-review-instructions-reset", + "Reset to default", + &[Field::ReviewInstructions], + ctx, + window, + cx, + ), + div().into_any_element(), + Some(area), + ctx, + window, + cx, + )); + } + let advanced = vec![ + self.setting( + "follow-change-request-templates", + "Follow change request templates", + "Use the repository's template for change request descriptions when available.", + Field::FollowTemplates, + self.reset( + "settings-follow-templates-reset", + "Reset to default", + &[Field::FollowTemplates], + ctx, + window, + cx, + ), + self.flag( + "settings-follow-templates", + Field::FollowTemplates, + ctx, + window, + cx, + ), + None, + ctx, + window, + cx, + ), + { + let model = self.model_control( + "settings-writer-model", + Field::WriterModel, + "Connect an environment to choose its source control writer model.", + ctx, + window, + cx, + ); + self.setting( + "source-control-writer-model", + "Source control writer model", + "Model for source control text and branch or bookmark names. When disabled, use the text generation model configured for this environment.", + Field::WriterModel, + None, + model, + None, + ctx, + window, + cx, + ) + }, + ]; + let view = cx.entity(); + rows.push(disclosure( + "settings-source-control-advanced", + self.advanced_open, + Rc::new(move |_, cx| { + view.update(cx, |this, cx| { + this.advanced_open = !this.advanced_open; + cx.notify(); + }) + }), + advanced, + window, + cx, + )); + section("Writing & review", group(rows, cx)) + } +} + +/// The marker id of a row id (a `'static` string per row). +fn marker_id(row: &'static str) -> &'static str { + match row { + "automatic-pull" => "settings-automatic-pull-marker", + "remove-agent-credits-on-merge" => "settings-agent-credits-marker", + "pull-request-merge-method" => "settings-merge-method-marker", + "source-control-writing-style" => "settings-writing-style-marker", + "review-guides" => "settings-review-guides-marker", + "review-guide-model" => "settings-review-guide-model-marker", + "review-guide-instructions" => "settings-review-instructions-marker", + "follow-change-request-templates" => "settings-follow-templates-marker", + _ => "settings-writer-model-marker", + } +} + +/// `"{label} availability"` switches need distinct ids; the server sends at most a handful. +const ELEMENT_SWITCH: [&str; 10] = [ + "settings-source-control-availability-0", + "settings-source-control-availability-1", + "settings-source-control-availability-2", + "settings-source-control-availability-3", + "settings-source-control-availability-4", + "settings-source-control-availability-5", + "settings-source-control-availability-6", + "settings-source-control-availability-7", + "settings-source-control-availability-8", + "settings-source-control-availability-9", +]; + +impl Render for SourceControlPanel { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let ctx = self.ctx(cx); + self.sync_inputs(&ctx, window, cx); + self.sync_hosts(window, cx); + let mut page = v_flex() + .w_full() + .gap_8() + .on_key_down(cx.listener(|this, event, window, cx| this.key_down(event, window, cx))) + .child(self.repositories(&ctx, window, cx)); + for part in self.discovery(&ctx, window, cx) { + page = page.child(part); + } + page.child(self.writing(&ctx, window, cx)) + } +} diff --git a/apps/desktop-gpui/src/ui/settings/source_control_panel/hosts.rs b/apps/desktop-gpui/src/ui/settings/source_control_panel/hosts.rs new file mode 100644 index 000000000000..fb3bc108b3ec --- /dev/null +++ b/apps/desktop-gpui/src/ui/settings/source_control_panel/hosts.rs @@ -0,0 +1,1258 @@ +//! The expanded panels of the provider rows: `SourceControlTokenSettings` (host and token), +//! `SourceControlAccountSettings` (the `gh` logins) and `SourceControlHostSettings` (the +//! schema-driven rows of Bitbucket and GitCafe). Every save is one `settings.update` patch of +//! `sourceControlHosts[kind]`, followed by a rescan once the server accepts it. + +use super::super::components::{KeyHandler, SelectTrigger, popup_shell, row, switch, tab_stop}; +use super::super::scope_inheritance::PopTrigger; +use super::super::secret_field::{Check, SecretEvent, SecretField, sm_field}; +use super::*; +use crate::secret_source as secret; +use crate::settings_source_control::HostAccountGroup; +use gpui::{Anchor, ElementId}; +use gpui_component::input::{InputEvent, InputState}; +use gpui_component::popover::Popover; +use serde_json::Map; + +/// `credentials` of the GitHub definition (`GITHUB_CREDENTIALS`). +const GITHUB_HOST: &str = "github.com"; +const TOKEN_ENV: &str = "GH_TOKEN"; +const CLI_NAME: &str = "gh"; +const CLI_LABEL: &str = "GitHub CLI"; +const TOKEN_ACCESS: &str = "read and write access to pull requests and contents"; +const LOGIN_COMMAND: &str = "gh auth login"; + +/// The inputs of the three expanded panels; the panel subscribes to each. +pub(super) struct HostForms { + pub github_host: Entity, + pub github_token: Entity, + /// What the token field was last loaded for: the host and the token saved under it. + github_key: String, + pub bitbucket_access: Entity, + pub bitbucket_api: Entity, + pub bitbucket_email: Entity, + pub gitcafe_token: Entity, + /// The saved value each other field was last loaded from. + synced: HashMap<&'static str, String>, +} + +impl HostForms { + pub(super) fn new(window: &mut Window, cx: &mut Context) -> Self { + let github_host = cx.new(|cx| { + InputState::new(window, cx) + .placeholder(GITHUB_HOST) + .default_value(GITHUB_HOST) + }); + let bitbucket_email = + cx.new(|cx| InputState::new(window, cx).placeholder("you@example.com")); + Self { + github_host, + github_token: cx.new(|cx| SecretField::new("github-token", "GitHub token", window, cx)), + github_key: "\u{0}".to_owned(), + bitbucket_access: cx + .new(|cx| SecretField::new("bitbucket-access-token", "Access token", window, cx)), + bitbucket_api: cx + .new(|cx| SecretField::new("bitbucket-api-token", "API token", window, cx)), + bitbucket_email, + gitcafe_token: cx.new(|cx| SecretField::new("gitcafe-token", "Token", window, cx)), + synced: HashMap::new(), + } + } + + /// What the panel listens to: each secret field, and the text inputs' blur and Enter. + pub(super) fn subscribe( + &self, + window: &mut Window, + cx: &mut Context, + ) -> Vec { + let mut subscriptions = Vec::new(); + for (kind, key, field) in self.secrets() { + subscriptions.push(cx.subscribe( + &field, + move |this, field, event: &SecretEvent, cx| { + this.secret_event(kind, key, &field, event, cx) + }, + )); + } + subscriptions.push(cx.subscribe_in( + &self.bitbucket_email, + window, + |this, _, event: &InputEvent, _, cx| { + if matches!(event, InputEvent::Blur | InputEvent::PressEnter { .. }) { + this.commit_host_text("bitbucket", "email", cx); + } + }, + )); + subscriptions.push(cx.subscribe_in( + &self.github_host, + window, + |this, _, event: &InputEvent, _, cx| match event { + InputEvent::Change => cx.notify(), + InputEvent::PressEnter { .. } => this.save_token(cx), + _ => {} + }, + )); + subscriptions + } + + /// Every secret field with the provider and settings key it edits. + pub(super) fn secrets(&self) -> [(&'static str, &'static str, Entity); 4] { + [ + ("github", "tokens", self.github_token.clone()), + ("bitbucket", "accessToken", self.bitbucket_access.clone()), + ("bitbucket", "apiToken", self.bitbucket_api.clone()), + ("gitcafe", "token", self.gitcafe_token.clone()), + ] + } +} + +/// A `Part` of a paragraph. +enum Part<'a> { + Text(&'a str), + Code(&'a str), +} + +/// A paragraph that wraps between words and holds `code` chips, as the web's inline markup does. +fn prose(parts: Vec, trailing: Option, cx: &App) -> gpui::Div { + let theme = Tokens::of(cx); + let mut line = h_flex() + .flex_wrap() + .items_center() + .gap_x(px(4.)) + .max_w(px(672.)) + .text_xs() + .line_height(px(18.)) + .text_color(theme.muted_foreground); + for part in parts { + match part { + Part::Text(text) => { + for word in text.split_whitespace() { + line = line.child(div().child(word.to_owned())); + } + } + Part::Code(text) => line = line.child(code(text, cx)), + } + } + line.children(trailing) +} + +/// ``. +fn code(text: &str, cx: &App) -> gpui::Div { + div() + .px_1() + .py(px(1.)) + .rounded(px(4.)) + .bg(Tokens::of(cx).muted) + .font_family(cx.theme().mono_font_family.clone()) + .text_size(px(10.)) + .child(text.to_owned()) +} + +/// `Button size="xs"` at the desktop size: `h-6 px-[7px] text-xs`. +fn xs_button( + id: impl Into, + variant: Variant, + enabled: bool, + label: &'static str, + cx: &App, +) -> gpui::Stateful { + button(id, variant, enabled, cx) + .h(px(24.)) + .px(px(7.)) + .text_size(px(12.)) + .line_height(px(16.)) + .child(label) +} + +/// `RedactedLogin`: the same letters for the same login, in mono at 10px. The web blurs them; +/// without a blur the letters are dimmed, and nothing here can be selected. +pub(super) fn redacted_login(account: &str, revealed: bool, cx: &App) -> gpui::Div { + let theme = Tokens::of(cx); + let (text, color) = if revealed { + (account.to_owned(), theme.muted_foreground) + } else { + (sc::redacted(account), theme.muted_foreground.opacity(0.4)) + }; + div() + .min_w_0() + .truncate() + .font_family(cx.theme().mono_font_family.clone()) + .text_size(px(10.)) + .line_height(px(15.)) + .text_color(color) + .child(text) +} + +/// A secret's patch value as the server stores it: a typed literal reads back as the marker. +fn masked(value: &Value) -> Value { + match value { + Value::String(text) if !text.is_empty() => secret::stored_literal(), + Value::Object(map) if map.get("provider").is_none() => Value::Object( + map.iter() + .map(|(key, inner)| (key.clone(), masked(inner))) + .collect(), + ), + other => other.clone(), + } +} + +impl SourceControlPanel { + /// Whether a host write is in flight (the web locks its fieldset meanwhile). + fn saving(&self) -> bool { + !self.host_writes.is_empty() + } + + /// Sends one `settings.update` of the provider's host settings, showing it at once. + /// `secrets` names the keys that hold secrets, which show as their marker until the reply. + fn save_host( + &mut self, + kind: &str, + fields: Map, + secrets: &[&str], + cx: &mut Context, + ) { + if fields.is_empty() || !self.ctx(cx).connected { + return; + } + let shown: Map = fields + .iter() + .map(|(key, value)| { + let value = if secrets.contains(&key.as_str()) { + masked(value) + } else { + value.clone() + }; + (key.clone(), value) + }) + .collect(); + let previous = self.config.clone(); + self.config.apply_host(kind, &shown); + let token = self.next_token; + self.next_token += 1; + let action = Action::Call { + token, + call: Call::UpdateSettings, + payload: SourceControlConfig::host_patch(kind, &fields), + }; + if self.actions.try_send(action).is_ok() { + self.pending.insert(token, previous); + self.host_writes.insert(token); + } else { + self.config = previous; + } + cx.notify(); + } + + /// The first rescan after a host write the server accepted. + pub(super) fn host_saved(&mut self, token: u64, cx: &mut Context) { + if self.host_writes.remove(&token) { + self.scan(cx); + } + } + + /// The outcome of a `server.checkSecretSource` request, reported to the field that asked. + pub(super) fn checked(&mut self, token: u64, event: CallEvent, cx: &mut Context) -> bool { + let Some(field) = self.checks.remove(&token) else { + return false; + }; + let state = match event { + CallEvent::Done(value) if value["_tag"] == "Resolved" => Check::Resolved, + CallEvent::Done(value) => Check::Failed( + value["detail"] + .as_str() + .unwrap_or("The secret could not be resolved.") + .to_owned(), + ), + CallEvent::Failed(message) => Check::Failed(message), + CallEvent::Progress(_) => { + self.checks.insert(token, field); + return true; + } + }; + field.update(cx, |field, cx| field.set_check(state, cx)); + true + } + + fn check( + &mut self, + field: &Entity, + provider: String, + reference: String, + cx: &mut Context, + ) { + let token = self.next_token; + self.next_token += 1; + let action = Action::Call { + token, + call: Call::CheckSecretSource, + payload: secret::check_payload(&provider, &reference), + }; + if self.actions.try_send(action).is_ok() { + self.checks.insert(token, field.clone()); + } else { + field.update(cx, |field, cx| { + field.set_check(Check::Failed("Could not reach the server.".to_owned()), cx) + }); + } + } + + /// What a secret field reported. + pub(super) fn secret_event( + &mut self, + kind: &'static str, + key: &'static str, + field: &Entity, + event: &SecretEvent, + cx: &mut Context, + ) { + match event { + SecretEvent::Changed => cx.notify(), + SecretEvent::Check(provider, reference) => { + self.check(field, provider.clone(), reference.clone(), cx) + } + SecretEvent::Commit if kind != "github" => { + if let Some(value) = field.read(cx).submission(cx) { + let mut fields = Map::new(); + fields.insert(key.to_owned(), value); + self.save_host(kind, fields, &[key], cx); + } + } + SecretEvent::Submit if kind == "github" => self.save_token(cx), + _ => {} + } + } + + /// The host input of the token form, as the web normalizes it. + fn github_host(&self, cx: &App) -> String { + self.hosts + .github_host + .read(cx) + .value() + .trim() + .to_lowercase() + } + + /// `save(normalizedHost, submission)` of `SourceControlTokenSettings`. + pub(super) fn save_token(&mut self, cx: &mut Context) { + let host = self.github_host(cx); + let Some(token) = self.hosts.github_token.read(cx).submission(cx) else { + return; + }; + if host.is_empty() || self.saving() { + return; + } + self.write_token(host, token, cx); + } + + fn write_token(&mut self, host: String, token: Value, cx: &mut Context) { + // The whole map goes out; the other hosts' markers keep their values. + let mut tokens: Map = self.config.tokens("github").into_iter().collect(); + tokens.insert(host, token); + let mut fields = Map::new(); + fields.insert("tokens".to_owned(), Value::Object(tokens)); + self.save_host("github", fields, &["tokens"], cx); + } + + /// Saves a text field of the host settings when it lost focus with a new value. + pub(super) fn commit_host_text( + &mut self, + kind: &'static str, + key: &'static str, + cx: &mut Context, + ) { + let (input, saved) = match (kind, key) { + ("bitbucket", "email") => ( + self.hosts.bitbucket_email.clone(), + self.config.host_value(kind, key), + ), + _ => return, + }; + let next = input.read(cx).value().trim().to_owned(); + if next != saved.as_str().unwrap_or_default() { + let mut fields = Map::new(); + fields.insert(key.to_owned(), Value::String(next)); + self.save_host(kind, fields, &[], cx); + } + } + + /// Puts what the server stores into the fields, once it changes; a typed draft goes with it. + pub(super) fn sync_hosts(&mut self, window: &mut Window, cx: &mut Context) { + let saving = self.saving(); + let sources = self.config.secret_sources().to_vec(); + for (kind, key, field) in self.hosts.secrets() { + field.update(cx, |field, cx| { + field.set_sources(&sources, cx); + field.set_disabled(saving, cx); + }); + if kind == "github" { + continue; + } + let saved = self.config.host_value(kind, key); + let signature = saved.to_string(); + if self.hosts.synced.get(key) != Some(&signature) { + self.hosts.synced.insert(key, signature); + field.update(cx, |field, cx| field.load(&saved, window, cx)); + } + } + let host = self.github_host(cx); + let saved = self + .config + .tokens("github") + .into_iter() + .find(|(candidate, _)| *candidate == host) + .map_or(Value::Null, |(_, value)| value); + let key = format!("{host}|{saved}"); + if key != self.hosts.github_key { + self.hosts.github_key = key; + self.hosts + .github_token + .update(cx, |field, cx| field.load(&saved, window, cx)); + } + let email = self.config.host_value("bitbucket", "email"); + let email = email.as_str().unwrap_or_default().to_owned(); + let input = &self.hosts.bitbucket_email; + if self.hosts.synced.get("email") != Some(&email) { + let focused = input.read(cx).focus_handle(cx).is_focused(window); + if !focused || !self.hosts.synced.contains_key("email") { + self.hosts.synced.insert("email", email.clone()); + input.update(cx, |state, cx| state.set_value(email, window, cx)); + } + } + } + + /// The chevron of a row that has details (`aria-label="Toggle {label} details"`). + pub(super) fn details_toggle( + &self, + index: usize, + item: &Item, + cx: &mut Context, + ) -> Option { + if !sc::has_host_settings(&item.kind) || !self.ctx(cx).connected { + return None; + } + let kind = item.kind.clone(); + let open = self.expanded.contains(&kind); + let view = cx.entity(); + let chevron = Icon::new(Icons::ChevronDown).size(px(14.)); + let chevron = if open { + chevron.rotate(gpui::radians(std::f32::consts::PI)) + } else { + chevron + }; + Some( + icon_button( + ("settings-source-control-details", index), + Variant::GhostMuted, + true, + cx, + ) + .size(px(24.)) + .tooltip(tooltip(format!("Toggle {} details", item.label))) + .on_click(move |_, _, cx| { + let kind = kind.clone(); + view.update(cx, |this, cx| { + if !this.expanded.remove(&kind) { + this.expanded.insert(kind); + } + cx.notify(); + }) + }) + .child(chevron) + .into_any_element(), + ) + } + + /// The panel under an expanded row (`px-3 pb-4 pt-1 sm:px-4`). + pub(super) fn host_details( + &self, + item: &Item, + window: &mut Window, + cx: &mut Context, + ) -> Option { + if !sc::has_host_settings(&item.kind) + || !self.expanded.contains(&item.kind) + || !self.ctx(cx).connected + { + return None; + } + let body = match item.kind.as_str() { + "github" => self.github_panel(item, window, cx), + "bitbucket" => self.bitbucket_panel(window, cx), + _ => self.gitcafe_panel(window, cx), + }; + Some(div().px_4().pb_4().pt_1().child(body).into_any_element()) + } + + fn github_panel(&self, item: &Item, window: &mut Window, cx: &mut Context) -> AnyElement { + let mut panel = v_flex().gap_6().child(self.token_form(window, cx)); + if item.available { + panel = panel.child(self.accounts(item, window, cx)); + } + panel.into_any_element() + } + + /// `SourceControlTokenSettings`. + fn token_form(&self, window: &mut Window, cx: &mut Context) -> gpui::Div { + let theme = Tokens::of(cx); + let saving = self.saving(); + let host = self.github_host(cx); + let tokens = self.config.tokens("github"); + let saved = tokens + .iter() + .find(|(candidate, _)| *candidate == host) + .map_or(Value::Null, |(_, value)| value.clone()); + let is_saved = secret::is_set(&saved); + let saved_hosts: Vec<&str> = tokens + .iter() + .filter(|(_, value)| secret::is_set(value)) + .map(|(candidate, _)| candidate.as_str()) + .collect(); + let submission = self.hosts.github_token.read(cx).submission(cx); + let can_save = !host.is_empty() && submission.is_some() && !saving; + let link_host = if host.is_empty() { + GITHUB_HOST + } else { + host.as_str() + }; + let url = format!("https://{link_host}/settings/personal-access-tokens/new"); + let view = cx.entity(); + let link = div() + .id("settings-source-control-create-token") + .flex() + .items_center() + .gap(px(2.)) + .cursor_pointer() + .text_color(theme.foreground) + .underline() + .on_click(move |_, _, cx| { + let _ = &view; + cx.open_url(&url) + }) + .child("Create a token") + .child(Icon::new(Icons::ExternalLink).size(px(12.))) + .into_any_element(); + let intro = prose( + vec![ + Part::Text("A token saved here is used before"), + Part::Code(TOKEN_ENV), + Part::Text("and the"), + Part::Code(CLI_NAME), + Part::Text("login, so GitHub works without the"), + Part::Text(CLI_LABEL), + Part::Text("."), + Part::Text("Give it"), + Part::Text(TOKEN_ACCESS), + Part::Text("."), + ], + Some(link), + cx, + ); + let label = |text: &'static str| { + div() + .text_sm() + .line_height(px(16.)) + .font_weight(FontWeight::MEDIUM) + .text_color(theme.foreground) + .child(text) + }; + let host_column = v_flex() + .gap(px(6.)) + .when(self.wide, |column| column.w(px(192.)).flex_shrink_0()) + .child(label("Host")) + .child(sm_field( + &self.hosts.github_host, + false, + false, + saving, + window, + cx, + )); + let token_column = v_flex() + .gap(px(6.)) + .min_w_0() + .flex_1() + .child(label("Token")) + .child(self.hosts.github_token.clone()); + let columns = if self.wide { + h_flex() + .items_start() + .gap_3() + .child(host_column) + .child(token_column) + } else { + v_flex().gap_3().child(host_column).child(token_column) + }; + let status = if saved_hosts.is_empty() { + format!("No token saved; the server uses {TOKEN_ENV} or the {CLI_NAME} login.") + } else { + format!("Saved for {}.", saved_hosts.join(", ")) + }; + let remove = { + let view = cx.entity(); + let host = host.clone(); + xs_button( + "settings-source-control-github-remove", + Variant::Outline, + !saving, + "Remove", + cx, + ) + .when(!saving, |button| { + button.on_click(move |_, _, cx| { + let host = host.clone(); + view.update(cx, |this, cx| { + this.write_token(host, Value::String(String::new()), cx) + }) + }) + }) + }; + let save = { + let view = cx.entity(); + xs_button( + "settings-source-control-github-save", + Variant::Primary, + can_save, + "Save", + cx, + ) + .when(can_save, |button| { + button.on_click(move |_, _, cx| view.update(cx, |this, cx| this.save_token(cx))) + }) + }; + v_flex().gap_3().child(intro).child(columns).child( + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + div() + .min_w_0() + .text_xs() + .text_color(theme.muted_foreground) + .child(status), + ) + .child( + h_flex() + .flex_shrink_0() + .gap_2() + .children(is_saved.then_some(remove)) + .child(save), + ), + ) + } + + /// The groups of a provider's logins, from the last scan. + fn account_groups(&self, kind: &str) -> Vec { + self.discovery + .as_ref() + .and_then(|discovery| discovery.providers.iter().find(|item| item.kind == kind)) + .and_then(|item| item.auth.as_ref()) + .map(|auth| sc::group_host_accounts(&auth.accounts)) + .unwrap_or_default() + } + + /// `save(host, change)` of `SourceControlAccountSettings`. + fn save_account( + &mut self, + kind: &'static str, + host: &str, + enabled: Option, + account: Option>, + cx: &mut Context, + ) { + if self.saving() { + return; + } + let next = + sc::next_host_account_choices(&self.config.host_choices(kind), host, enabled, account); + let mut fields = Map::new(); + fields.insert("hosts".to_owned(), sc::host_choices_value(&next)); + self.save_host(kind, fields, &[], cx); + } + + /// `SourceControlAccountSettings`. + fn accounts(&self, item: &Item, window: &mut Window, cx: &mut Context) -> AnyElement { + let theme = Tokens::of(cx); + let kind: &'static str = "github"; + let groups = self.account_groups(&item.kind); + if groups.is_empty() { + return prose( + vec![ + Part::Text("Sign in with"), + Part::Code(LOGIN_COMMAND), + Part::Text("on the server host, then rescan to choose accounts here."), + ], + None, + cx, + ) + .into_any_element(); + } + let saving = self.saving(); + let revealed = self.reveal_accounts.contains(&item.kind); + let choices = self.config.host_choices(kind); + let view = cx.entity(); + let eye = { + let view = view.clone(); + let name = item.kind.clone(); + icon_button( + "settings-source-control-github-reveal", + Variant::GhostMuted, + true, + cx, + ) + .size(px(24.)) + .tooltip(tooltip(if revealed { + "Hide GitHub accounts" + } else { + "Reveal GitHub accounts" + })) + .on_click(move |_, _, cx| { + let name = name.clone(); + view.update(cx, |this, cx| { + if !this.reveal_accounts.remove(&name) { + this.reveal_accounts.insert(name); + } + cx.notify(); + }) + }) + .child(Icon::new(if revealed { Icons::EyeOff } else { Icons::Eye }).size(px(14.))) + }; + let mut panel = v_flex().gap_4().child( + h_flex() + .items_center() + .justify_between() + .gap_3() + .child(prose( + vec![ + Part::Text("Choose which"), + Part::Code(CLI_NAME), + Part::Text("login each GitHub host uses, or turn a host off."), + ], + None, + cx, + )) + .child(eye), + ); + let muted = theme.muted_foreground; + for (index, group) in groups.iter().enumerate() { + let choice = choices.get(&group.host); + let enabled = choice.is_none_or(|choice| choice.enabled); + let stale = choice + .and_then(|choice| choice.account.as_deref()) + .filter(|account| { + !group + .selectable + .iter() + .any(|candidate| candidate == account) + }); + let pinned = choice + .and_then(|choice| choice.account.clone()) + .filter(|_| stale.is_none()); + let toggle = { + let (view, host) = (view.clone(), group.host.clone()); + let handler: Toggle = if saving { + Rc::new(|_, _| {}) + } else { + Rc::new(move |_, cx| { + let host = host.clone(); + view.update(cx, |this, cx| { + this.save_account(kind, &host, Some(!enabled), None, cx) + }) + }) + }; + let control = switch( + SharedString::from(format!("settings-source-control-github-host-{index}")), + enabled, + handler, + window, + cx, + ); + div() + .flex_shrink_0() + .when(saving, |control| control.opacity(0.64)) + .child(control) + }; + let mut block = v_flex().gap_2().child( + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + v_flex() + .min_w_0() + .gap(px(2.)) + .child( + div() + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(theme.foreground) + .child(group.host.clone()), + ) + .children(match group.selectable.as_slice() { + [only] => Some( + h_flex() + .min_w_0() + .items_center() + .gap_1() + .text_xs() + .text_color(muted) + .child("Signed in as") + .child(redacted_login(only, revealed, cx)), + ), + _ => None, + }), + ) + .child(toggle), + ); + if group.selectable.len() > 1 { + block = block.child( + h_flex() + .items_center() + .justify_between() + .gap_3() + .child(div().text_xs().text_color(muted).child("Account")) + .child(self.account_select( + index, + group, + pinned.as_deref(), + revealed, + !saving && enabled, + window, + cx, + )), + ); + } + if stale.is_some() { + let (view, host) = (view.clone(), group.host.clone()); + block = block.child( + h_flex() + .items_center() + .justify_between() + .gap_3() + .child( + div() + .text_xs() + .text_color(warning(&theme)) + .child("The chosen login is no longer signed in, so the active gh login is used."), + ) + .child( + xs_button( + SharedString::from(format!("settings-source-control-github-active-{index}")), + Variant::Outline, + !saving, + "Use active login", + cx, + ) + .when(!saving, |button| { + button.on_click(move |_, _, cx| { + let host = host.clone(); + view.update(cx, |this, cx| { + this.save_account(kind, &host, None, Some(None), cx) + }) + }) + }), + ), + ); + } + for entry in &group.broken { + block = block.child( + h_flex() + .min_w_0() + .flex_wrap() + .items_center() + .gap_1() + .text_xs() + .text_color(muted.opacity(0.7)) + .child(redacted_login(&entry.account, revealed, cx)) + .child(format!( + "can't be used: {}", + entry.error.clone().unwrap_or_else(|| format!( + "{CLI_NAME} reports this login as invalid." + )) + )), + ); + } + if let Some(variable) = &group.environment_variable { + block = block.child(div().text_xs().text_color(warning(&theme)).child(format!( + "{variable} is set on the server, so it overrides the account chosen here until it is unset." + ))); + } + panel = panel.child(block); + } + panel.into_any_element() + } + + /// The `Account` select of a host with several logins. + #[allow(clippy::too_many_arguments)] + fn account_select( + &self, + index: usize, + group: &HostAccountGroup, + pinned: Option<&str>, + revealed: bool, + enabled: bool, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let view = cx.entity(); + let host = group.host.clone(); + let handle = tab_stop( + &ElementId::from(SharedString::from(format!( + "settings-source-control-account-handle-{index}" + ))), + window, + cx, + ); + let active = |cx: &App| { + h_flex() + .min_w_0() + .items_center() + .gap_1() + .child(format!("Active {CLI_NAME} account")) + .children(group.active_account.as_deref().map(|account| { + h_flex() + .items_center() + .child("(") + .child(redacted_login(account, revealed, cx)) + .child(")") + })) + }; + let current = match pinned { + None => active(cx).into_any_element(), + Some(account) => redacted_login(account, revealed, cx).into_any_element(), + }; + let count = group.selectable.len() + 1; + let selected = pinned + .and_then(|account| { + group + .selectable + .iter() + .position(|candidate| candidate == account) + }) + .map_or(0, |position| position + 1); + let highlighted = self + .account_menu + .as_ref() + .filter(|(menu_host, _)| *menu_host == host) + .map(|(_, at)| *at); + let on_key: KeyHandler = { + let (view, host) = (view.clone(), host.clone()); + Rc::new(move |event, _, cx| { + let host = host.clone(); + view.update(cx, |this, cx| { + this.account_key(&host, count, selected, event, cx) + }) + }) + }; + let trigger = SelectTrigger::new( + SharedString::from(format!("settings-source-control-account-{index}")), + "", + 256., + handle, + on_key, + ) + .content(current); + if !enabled { + return div() + .w(px(256.)) + .max_w_full() + .opacity(0.64) + .child(trigger) + .into_any_element(); + } + let selectable = group.selectable.clone(); + let active_account = group.active_account.clone(); + let (opened, picked) = (view.clone(), view.clone()); + let popup_host = host.clone(); + let hover_host = host.clone(); + Popover::new(SharedString::from(format!( + "settings-source-control-account-menu-{index}" + ))) + .anchor(Anchor::TopRight) + .offset(px(4.)) + .open(highlighted.is_some()) + .p_0() + .appearance(false) + .on_open_change(move |next, _, cx| { + let host = host.clone(); + opened.update(cx, |this, cx| { + this.account_menu = next.then_some((host, selected)); + cx.notify(); + }) + }) + .trigger(PopTrigger::new(trigger)) + .content(move |_, _, cx| { + let theme = Tokens::of(cx); + let mut rows: Vec = Vec::new(); + for position in 0..count { + let account = position.checked_sub(1).map(|at| selectable[at].clone()); + let content: AnyElement = match &account { + None => h_flex() + .min_w_0() + .items_center() + .gap_1() + .child(format!("Active {CLI_NAME} account")) + .children(active_account.as_deref().map(|account| { + h_flex() + .items_center() + .child("(") + .child(redacted_login(account, revealed, cx)) + .child(")") + })) + .into_any_element(), + Some(account) => redacted_login(account, revealed, cx).into_any_element(), + }; + let (view, host) = (picked.clone(), popup_host.clone()); + let hover = (picked.clone(), hover_host.clone()); + rows.push( + div() + .id(("settings-source-control-account-item", position)) + .min_h(px(28.)) + .flex() + .items_center() + .px_2() + .py_1() + .rounded(px(4.)) + .text_sm() + .cursor_pointer() + .when(position == selected, |item| { + item.bg(theme.foreground.opacity(0.08)) + }) + .when(highlighted == Some(position), |item| { + item.bg(theme.accent).text_color(theme.accent_foreground) + }) + .on_mouse_move(move |_, _, cx| { + let host = hover.1.clone(); + hover.0.update(cx, |this, cx| { + if this.account_menu.as_ref() != Some(&(host.clone(), position)) { + this.account_menu = Some((host, position)); + cx.notify(); + } + }) + }) + .on_click(move |_, _, cx| { + let host = host.clone(); + let account = account.clone(); + view.update(cx, |this, cx| { + this.pick_account(&host, account.as_deref(), cx) + }) + }) + .child(content) + .into_any_element(), + ); + } + popup_shell(256., cx).children(rows) + }) + .into_any_element() + } + + fn pick_account(&mut self, host: &str, account: Option<&str>, cx: &mut Context) { + self.account_menu = None; + self.save_account("github", host, None, Some(account), cx); + cx.notify(); + } + + fn account_key( + &mut self, + host: &str, + count: usize, + selected: usize, + event: &KeyDownEvent, + cx: &mut Context, + ) { + let current = self + .account_menu + .as_ref() + .filter(|(menu_host, _)| menu_host == host) + .map(|(_, at)| *at); + let next = match (event.keystroke.key.as_str(), current) { + ("enter" | "space" | "down" | "up", None) => Some(selected), + ("down", Some(at)) => Some((at + 1) % count), + ("up", Some(at)) => Some((at + count - 1) % count), + ("enter" | "space", Some(at)) => { + cx.stop_propagation(); + let account = at.checked_sub(1).and_then(|at| { + self.account_groups("github") + .into_iter() + .find(|group| group.host == host) + .and_then(|group| group.selectable.get(at).cloned()) + }); + self.pick_account(host, account.as_deref(), cx); + return; + } + _ => return, + }; + cx.stop_propagation(); + self.account_menu = next.map(|at| (host.to_owned(), at)); + cx.notify(); + } + + /// The control of a secret row: the field at `w-80`, and `Clear {label}` once a value is saved. + fn secret_control( + &self, + kind: &'static str, + key: &'static str, + label: &'static str, + field: &Entity, + cx: &mut Context, + ) -> AnyElement { + let saved = self.config.host_value(kind, key); + let configured = secret::is_set(&saved); + let view = cx.entity(); + let saving = self.saving(); + let clear = icon_button( + SharedString::from(format!("settings-source-control-{kind}-{key}-clear")), + Variant::GhostDestructive, + !saving, + cx, + ) + .tooltip(tooltip(format!("Clear {label}"))) + .when(!saving, |button| { + button.on_click(move |_, _, cx| { + view.update(cx, |this, cx| { + let mut fields = Map::new(); + fields.insert(key.to_owned(), Value::String(String::new())); + this.save_host(kind, fields, &[], cx); + }) + }) + }) + .child(Icon::new(Icons::X).size(px(14.))); + h_flex() + .min_w_0() + .items_start() + .gap_2() + .when(self.wide, |control| control.w(px(320.))) + .when(!self.wide, |control| control.w_full()) + .child(div().min_w_0().flex_1().child(field.clone())) + .children(configured.then_some(clear)) + .into_any_element() + } + + /// The `Remove` row under the schema rows, while any value is saved. + fn remove_row( + &self, + kind: &'static str, + keys: &[&'static str], + cx: &mut Context, + ) -> Option { + let saved: Vec<&'static str> = keys + .iter() + .copied() + .filter(|key| { + let value = self.config.host_value(kind, key); + value + .as_str() + .map_or(!value.is_null(), |text| !text.is_empty()) + }) + .collect(); + if saved.is_empty() { + return None; + } + let saving = self.saving(); + let view = cx.entity(); + let keys: Vec<&'static str> = keys.to_vec(); + Some( + div() + .flex() + .justify_end() + .pt_2() + .child( + xs_button( + SharedString::from(format!("settings-source-control-{kind}-remove")), + Variant::Outline, + !saving, + "Remove", + cx, + ) + .when(!saving, |button| { + button.on_click(move |_, _, cx| { + let keys = keys.clone(); + view.update(cx, |this, cx| { + let fields = keys + .iter() + .map(|key| ((*key).to_owned(), Value::String(String::new()))) + .collect(); + this.save_host(kind, fields, &[], cx); + }) + }) + }), + ) + .into_any_element(), + ) + } + + fn bitbucket_panel(&self, window: &mut Window, cx: &mut Context) -> AnyElement { + let access = self.secret_control( + "bitbucket", + "accessToken", + "Access token", + &self.hosts.bitbucket_access, + cx, + ); + let api = self.secret_control( + "bitbucket", + "apiToken", + "API token", + &self.hosts.bitbucket_api, + cx, + ); + let saving = self.saving(); + let email = div() + .min_w_0() + .when(self.wide, |control| control.w(px(224.))) + .when(!self.wide, |control| control.w_full()) + .child(sm_field( + &self.hosts.bitbucket_email, + false, + false, + saving, + window, + cx, + )) + .into_any_element(); + let rows = v_flex() + .child(row( + "Access token", + "Scoped to one repository, project, or workspace. Create it in that item's Bitbucket settings.", + None, + access, + self.wide, + cx, + )) + .child(row( + "Atlassian account email", + "With an API token instead of an access token.", + None, + email, + self.wide, + cx, + )) + .child(row( + "API token", + "Reaches every repository your Atlassian account can. Give it read and write access to repositories and pull requests, and read:user:bitbucket.", + None, + api, + self.wide, + cx, + )); + v_flex() + .child(rows) + .children(self.remove_row("bitbucket", &["accessToken", "email", "apiToken"], cx)) + .into_any_element() + } + + fn gitcafe_panel(&self, _window: &mut Window, cx: &mut Context) -> AnyElement { + let token = self.secret_control("gitcafe", "token", "Token", &self.hosts.gitcafe_token, cx); + v_flex() + .child(row( + "Token", + "A git.cafe token, used before CAFE_TOKEN and the cafe login, so GitCafe works without the CLI signed in.", + None, + token, + self.wide, + cx, + )) + .children(self.remove_row("gitcafe", &["token"], cx)) + .into_any_element() + } +} diff --git a/apps/desktop-gpui/src/ui/settings/tokens.rs b/apps/desktop-gpui/src/ui/settings/tokens.rs index cdd460463375..16faffa293fb 100644 --- a/apps/desktop-gpui/src/ui/settings/tokens.rs +++ b/apps/desktop-gpui/src/ui/settings/tokens.rs @@ -98,7 +98,7 @@ impl Tokens { } /// `dialog-backdrop` (index.css:444-452): `--background` at 60%, 64% in dark. - pub(super) fn backdrop(&self) -> Hsla { + pub(crate) fn backdrop(&self) -> Hsla { self.background.opacity(if self.dark { 0.64 } else { 0.6 }) } } diff --git a/apps/desktop-gpui/src/ui/workspace_menu.rs b/apps/desktop-gpui/src/ui/workspace_menu.rs index 7ca752cbd0f2..b56760e1a916 100644 --- a/apps/desktop-gpui/src/ui/workspace_menu.rs +++ b/apps/desktop-gpui/src/ui/workspace_menu.rs @@ -81,7 +81,7 @@ fn content(menu: &Menu, cx: &App) -> gpui::Div { }) .collect(); composer_menu::popup( - "Workspace", + Some("Workspace"), menu.previous.is_some().then(|| px(338.)), rows, cx, From 0367f7559a7b9ef9338c21e36d535138b6f6ffc4 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 9 Nov 2026 17:16:44 -0500 Subject: [PATCH 69/82] feat(desktop-gpui): pull request rpc calls and error mapping Adds the pull request list, detail and action calls with the web's error copy for operation and unavailable failures. --- apps/desktop-gpui/src/connection.rs | 58 +++++++++ apps/desktop-gpui/src/main.rs | 1 + apps/desktop-gpui/src/pull_request.rs | 121 +++++++++++++++++++ apps/desktop-gpui/src/rpc.rs | 162 ++++++++++++++++++++++++++ 4 files changed, 342 insertions(+) create mode 100644 apps/desktop-gpui/src/pull_request.rs diff --git a/apps/desktop-gpui/src/connection.rs b/apps/desktop-gpui/src/connection.rs index cff9dec96bfc..7864e4a2bf5a 100644 --- a/apps/desktop-gpui/src/connection.rs +++ b/apps/desktop-gpui/src/connection.rs @@ -19,6 +19,12 @@ use crate::rpc::{ ProjectCloneStart, ProjectsListIgnoredFiles, ProjectsReadFile, ProjectsWriteFile, SubscribeProjectClones, }; +use crate::rpc::{ + PullRequestsActivity, PullRequestsChangeBreakdown, PullRequestsChecks, PullRequestsComment, + PullRequestsDetail, PullRequestsFilterOptions, PullRequestsInvalidate, + PullRequestsLinkedThreads, PullRequestsList, PullRequestsListStats, PullRequestsRouting, + PullRequestsRunAction, PullRequestsStack, PullRequestsSummary, +}; use crate::shell::ShellState; use crate::status::{CallEvent, Status, StatusSender, publish}; use crate::strip::{self, Branch}; @@ -122,6 +128,10 @@ pub enum Action { /// The RPCs an `Action::Call` can name, all with untyped payloads. #[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[expect( + dead_code, + reason = "the pull request surface constructs the PullRequests calls next" +)] pub enum Call { ListRefs, SwitchRef, @@ -152,6 +162,20 @@ pub enum Call { StartProjectClone, /// A stream of the clone list that never ends by itself. WatchProjectClones, + PullRequestsList, + PullRequestsListStats, + PullRequestsFilterOptions, + PullRequestsSummary, + PullRequestsRouting, + PullRequestsStack, + PullRequestsLinkedThreads, + PullRequestsDetail, + PullRequestsActivity, + PullRequestsChecks, + PullRequestsChangeBreakdown, + PullRequestsInvalidate, + PullRequestsRunAction, + PullRequestsComment, } pub type ActionSender = async_channel::Sender; @@ -209,6 +233,20 @@ impl Call { Self::WriteFile => "write the file", Self::StartProjectClone => "start the clone", Self::WatchProjectClones => "watch the clones", + Self::PullRequestsList => "list the pull requests", + Self::PullRequestsListStats => "count the pull requests", + Self::PullRequestsFilterOptions => "load the list filters", + Self::PullRequestsSummary => "load the pull request", + Self::PullRequestsRouting => "route the pull request", + Self::PullRequestsStack => "load the stack", + Self::PullRequestsLinkedThreads => "load the linked threads", + Self::PullRequestsDetail => "load the pull request", + Self::PullRequestsActivity => "load the activity", + Self::PullRequestsChecks => "load the checks", + Self::PullRequestsChangeBreakdown => "load the changed files", + Self::PullRequestsInvalidate => "refresh the pull request", + Self::PullRequestsRunAction => "run the pull request action", + Self::PullRequestsComment => "post the comment", } } @@ -245,6 +283,26 @@ impl Call { Self::WriteFile => session.request::(payload), Self::StartProjectClone => session.request::(payload), Self::WatchProjectClones => session.request::(payload), + Self::PullRequestsList => session.request::(payload), + Self::PullRequestsListStats => session.request::(payload), + Self::PullRequestsFilterOptions => { + session.request::(payload) + } + Self::PullRequestsSummary => session.request::(payload), + Self::PullRequestsRouting => session.request::(payload), + Self::PullRequestsStack => session.request::(payload), + Self::PullRequestsLinkedThreads => { + session.request::(payload) + } + Self::PullRequestsDetail => session.request::(payload), + Self::PullRequestsActivity => session.request::(payload), + Self::PullRequestsChecks => session.request::(payload), + Self::PullRequestsChangeBreakdown => { + session.request::(payload) + } + Self::PullRequestsInvalidate => session.request::(payload), + Self::PullRequestsRunAction => session.request::(payload), + Self::PullRequestsComment => session.request::(payload), } } } diff --git a/apps/desktop-gpui/src/main.rs b/apps/desktop-gpui/src/main.rs index 22481e143b31..d208186ed634 100644 --- a/apps/desktop-gpui/src/main.rs +++ b/apps/desktop-gpui/src/main.rs @@ -17,6 +17,7 @@ mod model_picker; mod pairing; mod projects; mod providers; +mod pull_request; mod ref_picker; mod refs; mod rpc; diff --git a/apps/desktop-gpui/src/pull_request.rs b/apps/desktop-gpui/src/pull_request.rs new file mode 100644 index 000000000000..4ad72df18e32 --- /dev/null +++ b/apps/desktop-gpui/src/pull_request.rs @@ -0,0 +1,121 @@ +//! The web's pull request reads (`packages/contracts/src/pullRequest.ts`): the payloads the +//! panel sends and the way a failure is told apart. + +#![expect(dead_code, reason = "the pull request surface consumes these next")] + +use crate::rpc::split_not_found; +use serde_json::{Value, json}; + +/// `PullRequestRef`: the project, the repository on its host and the number. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct Reference { + pub project_id: String, + pub host: Option, + pub repository: String, + pub number: u64, +} + +impl Reference { + /// `pullRequestPanelReference` (the web keys queries on this field order). + pub fn payload(&self) -> Value { + let mut payload = json!({ + "projectId": self.project_id, + "repository": self.repository, + "number": self.number, + }); + if let Some(host) = self.host.as_ref().filter(|host| !host.is_empty()) { + payload["host"] = json!(host); + } + payload + } + + /// `usePullRequestDetail` reads the detail with `allowStale: false`. + pub fn fresh_payload(&self) -> Value { + let mut payload = self.payload(); + payload["allowStale"] = json!(false); + payload + } + + /// `pullRequestRefKey`. + pub fn key(&self) -> String { + format!( + "{}:{}:{}#{}", + self.project_id, + self.host.as_deref().unwrap_or(""), + self.repository, + self.number + ) + } +} + +/// `PullRequestListInput` for the page's first read of one state. +pub fn list_payload(state: &str, project_ids: &[String], limit: u32) -> Value { + let mut payload = json!({ "state": state, "limit": limit }); + if !project_ids.is_empty() { + payload["projectIds"] = json!(project_ids); + } + payload +} + +/// What a failed read shows: `PullRequestsUnavailableState`'s title and description. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Unavailable { + pub title: String, + pub error: String, +} + +pub const UNAVAILABLE_TITLE: &str = "Could not load pull requests"; + +/// The detail panel's failure: a not-found reason gets its own title and copy. +pub fn detail_failure(message: &str, number: u64) -> Unavailable { + match split_not_found(message) { + (_, true) => Unavailable { + title: format!("Pull request #{number} not found"), + error: "It may be an issue rather than a pull request, or this account can't see it." + .to_owned(), + }, + (text, false) => Unavailable { + title: UNAVAILABLE_TITLE.to_owned(), + error: text.to_owned(), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rpc::NOT_FOUND_MARK; + + fn reference(host: Option<&str>) -> Reference { + Reference { + project_id: "p1".to_owned(), + host: host.map(str::to_owned), + repository: "owner/repo".to_owned(), + number: 7, + } + } + + #[test] + fn the_reference_carries_a_host_only_when_known() { + assert_eq!( + reference(None).payload(), + json!({"projectId": "p1", "repository": "owner/repo", "number": 7}) + ); + assert_eq!( + reference(Some("example.test")).payload()["host"], + "example.test" + ); + assert_eq!(reference(Some("")).payload().get("host"), None); + assert_eq!(reference(None).fresh_payload()["allowStale"], false); + assert_eq!(reference(Some("h")).key(), "p1:h:owner/repo#7"); + } + + #[test] + fn a_missing_pull_request_has_its_own_title() { + let missing = detail_failure(&format!("{NOT_FOUND_MARK}gone"), 7); + assert_eq!(missing.title, "Pull request #7 not found"); + let other = detail_failure("paused", 7); + assert_eq!(other.title, UNAVAILABLE_TITLE); + assert_eq!(other.error, "paused"); + } +} diff --git a/apps/desktop-gpui/src/rpc.rs b/apps/desktop-gpui/src/rpc.rs index fbfe523ecd0a..926db5916373 100644 --- a/apps/desktop-gpui/src/rpc.rs +++ b/apps/desktop-gpui/src/rpc.rs @@ -306,6 +306,76 @@ untyped_method!( ProjectCloneStart, "projectClone.start" ); +untyped_method!( + /// `pullRequests.list`: the pull requests of the projects on the environment. + PullRequestsList, + "pullRequests.list" +); +untyped_method!( + /// `pullRequests.listStats`: the list's counts per state. + PullRequestsListStats, + "pullRequests.listStats" +); +untyped_method!( + /// `pullRequests.filterOptions`: the authors and labels the list can filter by. + PullRequestsFilterOptions, + "pullRequests.filterOptions" +); +untyped_method!( + /// `pullRequests.summary`: one pull request's row. + PullRequestsSummary, + "pullRequests.summary" +); +untyped_method!( + /// `pullRequests.routing`: the account and host a pull request is read through. + PullRequestsRouting, + "pullRequests.routing" +); +untyped_method!( + /// `pullRequests.stack`: the stack a pull request belongs to, or null. + PullRequestsStack, + "pullRequests.stack" +); +untyped_method!( + /// `pullRequests.linkedThreads`: the threads linked to a pull request. + PullRequestsLinkedThreads, + "pullRequests.linkedThreads" +); +untyped_method!( + /// `pullRequests.detail`: a pull request's detail. + PullRequestsDetail, + "pullRequests.detail" +); +untyped_method!( + /// `pullRequests.activity`: a pull request's timeline. + PullRequestsActivity, + "pullRequests.activity" +); +untyped_method!( + /// `pullRequests.checks`: a pull request's checks, or null. + PullRequestsChecks, + "pullRequests.checks" +); +untyped_method!( + /// `pullRequests.changeBreakdown`: a pull request's files and commits. + PullRequestsChangeBreakdown, + "pullRequests.changeBreakdown" +); +untyped_method!( + /// `pullRequests.invalidate`: drops the server's cached read so the next one hits the host. + PullRequestsInvalidate, + "pullRequests.invalidate" +); +untyped_method!( + /// `pullRequests.runAction`: merge, close, reopen, draft and the other actions. + PullRequestsRunAction, + "pullRequests.runAction" +); +untyped_method!( + /// `pullRequests.comment`: posts a comment. + PullRequestsComment, + "pullRequests.comment" +); /// `subscribeProjectClones`, a stream of the clone list that stays open until interrupted. pub struct SubscribeProjectClones; @@ -514,10 +584,65 @@ fn describe_cause(cause: &[CauseFrame]) -> String { } } +/// Leads the message of a pull request failure the host reported as not found, so the panel can +/// title it as the web does; `split_not_found` removes it. +pub const NOT_FOUND_MARK: &str = "\u{1f}not-found\u{1f}"; + +/// The message and whether it carries `NOT_FOUND_MARK`. +pub fn split_not_found(message: &str) -> (&str, bool) { + match message.strip_prefix(NOT_FOUND_MARK) { + Some(rest) => (rest, true), + None => (message, false), + } +} + +/// `PullRequestUnavailableError.message` and `PullRequestOperationError.message`, which are +/// getters and so are not on the wire. +fn pull_request_message(error: &Value) -> Option { + let field = |key: &str| { + error + .get(key) + .and_then(Value::as_str) + .filter(|t| !t.is_empty()) + }; + match error.get("_tag").and_then(Value::as_str)? { + "PullRequestOperationError" => { + let text = format!( + "Pull request operation {} failed: {}", + field("operation")?, + field("detail")? + ); + Some(if field("reason") == Some("not-found") { + format!("{NOT_FOUND_MARK}{text}") + } else { + text + }) + } + "PullRequestUnavailableError" => Some( + match field("reason")? { + "cli-missing" => field("requirement") + .unwrap_or("The tool this host is read through is not installed or set up."), + "cli-unauthenticated" => { + field("requirement").unwrap_or("This host has no working credentials.") + } + "provider-unsupported" => { + "Change requests cannot be browsed for this project's host yet." + } + _ => return None, + } + .to_owned(), + ), + _ => None, + } +} + fn error_message(error: &Value) -> String { if let Some(text) = error.as_str() { return text.to_owned(); } + if let Some(text) = pull_request_message(error) { + return text; + } ["message", "reason", "detail", "_tag"] .iter() .find_map(|key| error.get(key).and_then(Value::as_str)) @@ -529,6 +654,43 @@ fn error_message(error: &Value) -> String { mod tests { use super::*; + #[test] + fn pull_request_failures_read_as_the_web_message() { + let operation = json!({ + "_tag": "PullRequestOperationError", + "operation": "detail", + "detail": "requests are paused", + }); + assert_eq!( + error_message(&operation), + "Pull request operation detail failed: requests are paused" + ); + let missing = json!({ + "_tag": "PullRequestOperationError", + "operation": "detail", + "detail": "no such pull request", + "reason": "not-found", + }); + let message = error_message(&missing); + let (text, not_found) = split_not_found(&message); + assert!(not_found); + assert_eq!( + text, + "Pull request operation detail failed: no such pull request" + ); + assert_eq!(split_not_found("plain"), ("plain", false)); + let unavailable = json!({ + "_tag": "PullRequestUnavailableError", + "reason": "cli-unauthenticated", + "requirement": "Run gh auth login.", + }); + assert_eq!(error_message(&unavailable), "Run gh auth login."); + assert_eq!( + error_message(&json!({"_tag": "PullRequestUnavailableError", "reason": "cli-missing"})), + "The tool this host is read through is not installed or set up." + ); + } + #[test] fn request_frame_matches_the_effect_envelope() { let mut session = Session::new(); From 5529067a6ec0238675da0b12ea25e0192f563b16 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 9 Nov 2026 17:20:51 -0500 Subject: [PATCH 70/82] feat(desktop-gpui): pull request tabs in the right panel PR badge items open the pull request as a tab in the right panel, with loading and the web's unavailable state, retry, and open on host. --- apps/desktop-gpui/src/pull_request.rs | 35 ++- apps/desktop-gpui/src/shell.rs | 3 + apps/desktop-gpui/src/status.rs | 2 + apps/desktop-gpui/src/thread_card.rs | 4 + apps/desktop-gpui/src/ui.rs | 63 ++++- .../desktop-gpui/src/ui/pull_request_panel.rs | 261 ++++++++++++++++++ apps/desktop-gpui/src/ui/right_panel.rs | 204 +++++++++++--- 7 files changed, 521 insertions(+), 51 deletions(-) create mode 100644 apps/desktop-gpui/src/ui/pull_request_panel.rs diff --git a/apps/desktop-gpui/src/pull_request.rs b/apps/desktop-gpui/src/pull_request.rs index 4ad72df18e32..ff57d3c17e14 100644 --- a/apps/desktop-gpui/src/pull_request.rs +++ b/apps/desktop-gpui/src/pull_request.rs @@ -1,8 +1,6 @@ //! The web's pull request reads (`packages/contracts/src/pullRequest.ts`): the payloads the //! panel sends and the way a failure is told apart. -#![expect(dead_code, reason = "the pull request surface consumes these next")] - use crate::rpc::split_not_found; use serde_json::{Value, json}; @@ -48,6 +46,7 @@ impl Reference { } } +#[expect(dead_code, reason = "the pull requests page reads with it")] /// `PullRequestListInput` for the page's first read of one state. pub fn list_payload(state: &str, project_ids: &[String], limit: u32) -> Value { let mut payload = json!({ "state": state, "limit": limit }); @@ -81,6 +80,27 @@ pub fn detail_failure(message: &str, number: u64) -> Unavailable { } } +/// `openOnHostLabel` for the host a pull request URL names. The web resolves the provider +/// from the repository identity; the URL's host stands in for it, so a self-hosted instance +/// reads "Open on host". +pub fn open_label(url: &str) -> String { + let host = url + .split_once("://") + .map_or(url, |(_, rest)| rest) + .split(['/', ':']) + .next() + .unwrap_or("") + .to_ascii_lowercase(); + let host = host.strip_prefix("www.").unwrap_or(&host); + match host { + "github.com" => "Open on GitHub".to_owned(), + "gitlab.com" => "Open on GitLab".to_owned(), + "bitbucket.org" => "Open on Bitbucket".to_owned(), + "dev.azure.com" => "Open on Azure DevOps".to_owned(), + _ => "Open on host".to_owned(), + } +} + #[cfg(test)] mod tests { use super::*; @@ -118,4 +138,15 @@ mod tests { assert_eq!(other.title, UNAVAILABLE_TITLE); assert_eq!(other.error, "paused"); } + + #[test] + fn the_open_label_names_the_known_hosts() { + assert_eq!( + open_label("https://github.com/o/r/pull/1"), + "Open on GitHub" + ); + assert_eq!(open_label("https://www.gitlab.com/o/r"), "Open on GitLab"); + assert_eq!(open_label("https://git.example.test/o/r"), "Open on host"); + assert_eq!(open_label(""), "Open on host"); + } } diff --git a/apps/desktop-gpui/src/shell.rs b/apps/desktop-gpui/src/shell.rs index 9cbef0df9a87..278346fc79af 100644 --- a/apps/desktop-gpui/src/shell.rs +++ b/apps/desktop-gpui/src/shell.rs @@ -306,6 +306,7 @@ fn pr_badge(thread: &Thread) -> Option { text: linked.number.to_string(), url: Some(linked.url.clone()), opens_list: false, + repository: Some(linked.repository.clone()), }); } let state_of = |link: &&PullRequestsItem| match link.snapshot.as_ref() { @@ -327,6 +328,7 @@ fn pr_badge(thread: &Thread) -> Option { text: only.number.to_string(), url: Some(only.url.clone()), opens_list: false, + repository: None, }); } let states: Vec = visible.iter().map(state_of).collect(); @@ -349,6 +351,7 @@ fn pr_badge(thread: &Thread) -> Option { stack, url: None, opens_list: true, + repository: None, text: if stack { visible.len().to_string() } else { diff --git a/apps/desktop-gpui/src/status.rs b/apps/desktop-gpui/src/status.rs index 7118dca5f33c..54614924142e 100644 --- a/apps/desktop-gpui/src/status.rs +++ b/apps/desktop-gpui/src/status.rs @@ -155,6 +155,8 @@ pub struct PrBadge { pub url: Option, /// `opensList`: a stack or several pull requests, so a click shows the list, not a link. pub opens_list: bool, + /// The repository of a link that has no snapshot or list line yet, to open its detail. + pub repository: Option, } /// The status the web's thread card shows at the top right (`topStatus` in `SidebarThreadRow`). diff --git a/apps/desktop-gpui/src/thread_card.rs b/apps/desktop-gpui/src/thread_card.rs index ebe09f1da9ea..612c2265199f 100644 --- a/apps/desktop-gpui/src/thread_card.rs +++ b/apps/desktop-gpui/src/thread_card.rs @@ -26,6 +26,8 @@ pub struct PrLine { pub title: String, pub depth: usize, pub url: String, + pub host: String, + pub repository: String, /// On a chain's first line: its kind and how many pull requests it holds. pub stack: Option<(ChainKind, usize)>, } @@ -220,6 +222,8 @@ pub fn pr_lines(links: &[PullRequestsItem]) -> Vec { .map_or_else(|| link.repository.clone(), |s| s.title.clone()), depth: chain.depths[position], url: link.url.clone(), + host: link.host.clone(), + repository: link.repository.clone(), stack: (position == 0 && chain.layers.len() > 1) .then_some((chain.kind, chain.layers.len())), } diff --git a/apps/desktop-gpui/src/ui.rs b/apps/desktop-gpui/src/ui.rs index 5ed8853e51c4..9582a447cd7a 100644 --- a/apps/desktop-gpui/src/ui.rs +++ b/apps/desktop-gpui/src/ui.rs @@ -59,6 +59,7 @@ const TERMINAL_HANDLE_HEIGHT: f32 = 8.; mod add_to_machine; mod composer_menu; mod model_popover; +mod pull_request_panel; mod right_panel; mod run_on_menu; mod settings; @@ -283,6 +284,10 @@ pub struct MainView { /// Directories whose diff was asked for on this connection. diffs_requested: HashSet, diff_list: Entity, + /// The pull request tabs opened per thread, and the surface each panel shows. + pull_request_tabs: HashMap>, + right_panel_active: HashMap, + pull_request_panel: Entity, /// Threads (and drafts, by project) whose terminal drawer is open; it starts closed. terminal_open: HashSet, /// The drawer's height per thread, as dragged. @@ -484,6 +489,12 @@ impl MainView { diffs: HashMap::new(), diffs_requested: HashSet::new(), diff_list: cx.new(|_| right_panel::DiffList::new()), + pull_request_tabs: HashMap::new(), + right_panel_active: HashMap::new(), + pull_request_panel: cx.new({ + let actions = actions.clone(); + move |_| pull_request_panel::PullRequestPanel::new(actions) + }), terminal_open: HashSet::new(), terminal_heights: HashMap::new(), terminals: HashMap::new(), @@ -642,6 +653,11 @@ impl MainView { return cx.notify(); } if let Status::Call { token, event } = status { + if token >= pull_request_panel::TOKEN_BASE { + self.pull_request_panel + .update(cx, |panel, cx| panel.on_call(token, event, cx)); + return; + } if token >= add_to_machine::TOKEN_BASE { self.add_to_machine .update(cx, |dialog, cx| dialog.on_call(token, event, cx)); @@ -690,6 +706,8 @@ impl MainView { self.branches_requested.clear(); self.vcs.clear(); self.diffs_requested.clear(); + self.pull_request_panel + .update(cx, |panel, cx| panel.reset(cx)); } let access = match &status { Status::NotConnected => usage_dialog::Access::Unavailable, @@ -2339,11 +2357,34 @@ impl MainView { let pr_control = row.pr.as_ref().map(|badge| { let weak = cx.weak_entity(); let list_id = row.id.clone(); - pr_badge_control(row, badge, muted, dark, move |cx| { - let list_id = list_id.clone(); - weak.update(cx, |this, cx| this.open_thread(Some(list_id), cx)) + let (open_weak, thread, project_id) = + (weak.clone(), row.id.clone(), row.project_id.clone()); + let open: OpenPullRequest = Rc::new(move |line, cx| { + let reference = crate::pull_request::Reference { + project_id: project_id.clone(), + host: Some(line.host.clone()).filter(|host| !host.is_empty()), + repository: line.repository.clone(), + number: line.number, + }; + let (thread, url) = (thread.clone(), Some(line.url.clone())); + open_weak + .update(cx, |this, cx| { + this.open_pull_request(thread, reference, url, cx) + }) .ok(); - }) + }); + pr_badge_control( + row, + badge, + muted, + dark, + move |cx| { + let list_id = list_id.clone(); + weak.update(cx, |this, cx| this.open_thread(Some(list_id), cx)) + .ok(); + }, + open, + ) }); thread_row( row, @@ -5987,6 +6028,8 @@ fn badge_lines(badge: &PrBadge, lines: &[PrLine]) -> Vec { title: format!("Pull request #{number}"), depth: 0, url: url.clone(), + host: String::new(), + repository: badge.repository.clone().unwrap_or_default(), stack: None, }], _ => Vec::new(), @@ -6022,6 +6065,7 @@ fn popup_offset( /// The web's `PullRequestBadge` tooltip popup: a mini list whose items open their pull request. struct PrPopup { lines: Vec, + open: OpenPullRequest, place: gpui::Point, height: f32, } @@ -6032,7 +6076,7 @@ impl Render for PrPopup { let (muted, foreground, accent) = (theme.muted_foreground, theme.foreground, theme.accent); let (dark, mono) = (theme.is_dark(), theme.mono_font_family.clone()); let items = self.lines.iter().enumerate().map(|(index, line)| { - let url = line.url.clone(); + let (line_to_open, open) = (line.clone(), self.open.clone()); pr_line_row(line, mono.clone(), muted, foreground, dark) .id(("pr-popup-item", index)) .px_1() @@ -6043,7 +6087,7 @@ impl Render for PrPopup { .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) .on_click(move |_, _, cx| { cx.stop_propagation(); - cx.open_url(&url); + open(&line_to_open, cx); }) }); div().relative().left(self.place.x).top(self.place.y).child( @@ -6065,6 +6109,9 @@ impl Render for PrPopup { } } +/// Opens one popup item's pull request in the right panel. +type OpenPullRequest = Rc; + /// The badge in a row's meta line: a link for one pull request, else it opens the list. Hovering /// shows the popup. The web opens the right panel's pull requests view for a list; GPUI has /// none, so it activates the thread and the popup stays the list. @@ -6074,6 +6121,7 @@ fn pr_badge_control( muted: Hsla, dark: bool, on_list: impl Fn(&mut App) + 'static, + open: OpenPullRequest, ) -> gpui::Stateful { let tone = pr_color(badge.state, dark).unwrap_or(muted); let anchor = Rc::new(Cell::new(Bounds::default())); @@ -6109,8 +6157,10 @@ fn pr_badge_control( let height = popup_height(lines.len(), room); let place = popup_offset(popup_anchor.get(), window.mouse_position(), height); let lines = lines.clone(); + let open = open.clone(); cx.new(|_| PrPopup { lines, + open, place, height, }) @@ -6693,6 +6743,7 @@ mod tests { text: "19".to_owned(), url: None, opens_list: false, + repository: None, }; let plain = branch_budget(300., true, None, true); assert!(branch_budget(300., true, Some(&badge), true) < plain); diff --git a/apps/desktop-gpui/src/ui/pull_request_panel.rs b/apps/desktop-gpui/src/ui/pull_request_panel.rs new file mode 100644 index 000000000000..c5bcb337fa41 --- /dev/null +++ b/apps/desktop-gpui/src/ui/pull_request_panel.rs @@ -0,0 +1,261 @@ +//! The web's `pull-request` right panel surface (`PullRequestDetailPanel.tsx`): the detail of one +//! pull request, read through the window's connection. A failed read shows +//! `PullRequestsUnavailableState`. + +use super::*; +use crate::pull_request::{self, Reference}; +use crate::status::CallEvent; +use gpui_component::Disableable as _; +use serde_json::Value; + +/// Call tokens, above every other range. +pub const TOKEN_BASE: u64 = 1 << 48; + +/// What the detail read has produced for one pull request. +enum Load { + Loading, + /// `refreshing` while a retry is on its way, like `refreshing={detailQuery.isPending}`. + Failed { + message: String, + refreshing: bool, + }, + Loaded(Value), +} + +/// The pull request on screen, with the link the host's own page opens at. +struct Shown { + reference: Reference, + url: Option, +} + +pub struct PullRequestPanel { + actions: ActionSender, + loads: HashMap, + tokens: HashMap, + next_token: u64, + shown: Option, +} + +impl PullRequestPanel { + pub fn new(actions: ActionSender) -> Self { + Self { + actions, + loads: HashMap::new(), + tokens: HashMap::new(), + next_token: TOKEN_BASE, + shown: None, + } + } + + /// Shows `reference`, reading it the first time. + pub fn show(&mut self, reference: Reference, url: Option, cx: &mut Context) { + let key = reference.key(); + let same = self + .shown + .as_ref() + .is_some_and(|shown| shown.reference == reference); + if !same { + self.shown = Some(Shown { + reference: reference.clone(), + url, + }); + cx.notify(); + } + if !self.loads.contains_key(&key) { + self.request(&reference); + cx.notify(); + } + } + + /// A reconnect drops what the old connection answered. + pub fn reset(&mut self, cx: &mut Context) { + self.loads.clear(); + self.tokens.clear(); + if let Some(shown) = &self.shown { + let reference = shown.reference.clone(); + self.request(&reference); + } + cx.notify(); + } + + fn request(&mut self, reference: &Reference) { + let key = reference.key(); + self.tokens.retain(|_, pending| *pending != key); + self.next_token += 1; + let token = self.next_token; + self.tokens.insert(token, key.clone()); + let previous = self.loads.remove(&key); + self.loads.insert( + key.clone(), + match previous { + Some(Load::Failed { message, .. }) => Load::Failed { + message, + refreshing: true, + }, + Some(loaded @ Load::Loaded(_)) => loaded, + _ => Load::Loading, + }, + ); + let action = Action::Call { + token, + call: Call::PullRequestsDetail, + payload: reference.fresh_payload(), + }; + if self.actions.try_send(action).is_err() { + self.tokens.remove(&token); + self.loads.insert( + key, + Load::Failed { + message: "Could not send the request".to_owned(), + refreshing: false, + }, + ); + eprintln!("[pull-request] could not send the request"); + } + } + + pub fn on_call(&mut self, token: u64, event: CallEvent, cx: &mut Context) { + if matches!(event, CallEvent::Progress(_)) { + return; + } + let Some(key) = self.tokens.remove(&token) else { + return; + }; + let load = match event { + CallEvent::Done(value) => Load::Loaded(value), + CallEvent::Failed(message) => Load::Failed { + message, + refreshing: false, + }, + CallEvent::Progress(_) => return, + }; + self.loads.insert(key, load); + cx.notify(); + } + + fn retry(&mut self, cx: &mut Context) { + let Some(reference) = self.shown.as_ref().map(|shown| shown.reference.clone()) else { + return; + }; + self.request(&reference); + cx.notify(); + } + + fn note(text: &'static str, cx: &App) -> gpui::Div { + div() + .p_3() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(text) + } + + /// `PullRequestsUnavailableState`: an icon card, the title and reason, and the buttons. + fn unavailable( + &self, + shown: &Shown, + message: &str, + refreshing: bool, + cx: &mut Context, + ) -> gpui::Div { + let theme = cx.theme(); + let failure = pull_request::detail_failure(message, shown.reference.number); + let open = shown.url.clone(); + let label = pull_request::open_label(open.as_deref().unwrap_or("")); + v_flex() + .size_full() + .items_center() + .justify_center() + .gap_6() + .p_6() + .text_center() + .child( + div() + .size(px(36.)) + .flex() + .items_center() + .justify_center() + .rounded(px(6.)) + .border_1() + .border_color(theme.border) + .bg(theme.background) + .shadow_sm() + .child(Icon::new(Icons::GitPullRequestArrow).size(px(18.))), + ) + .child( + v_flex() + .max_w(px(384.)) + .items_center() + .gap_1() + .child( + div() + .text_lg() + .font_weight(FontWeight::MEDIUM) + .child(failure.title), + ) + .child( + div() + .text_sm() + .text_color(theme.muted_foreground) + .child(failure.error), + ), + ) + .child( + h_flex() + .flex_wrap() + .justify_center() + .gap_2() + .child( + Button::new("pull-request-retry") + .label("Retry") + .icon(Icon::new(Icons::RefreshCw)) + .outline() + .small() + .disabled(refreshing) + .on_click(cx.listener(|this, _, _, cx| this.retry(cx))), + ) + .when_some(open, |row, url| { + row.child( + Button::new("pull-request-open") + .label(label) + .icon(Icon::new(Icons::ExternalLink)) + .outline() + .small() + .on_click(move |_, _, cx| cx.open_url(&url)), + ) + }), + ) + } +} + +impl Render for PullRequestPanel { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let Some(shown) = &self.shown else { + return div().into_any_element(); + }; + match self.loads.get(&shown.reference.key()) { + None | Some(Load::Loading) => { + Self::note("Loading pull request...", cx).into_any_element() + } + Some(Load::Failed { + message, + refreshing, + }) => self + .unavailable(shown, message, *refreshing, cx) + .into_any_element(), + Some(Load::Loaded(detail)) => { + let title = detail["title"].as_str().unwrap_or_default().to_owned(); + v_flex() + .p_3() + .gap_1() + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(format!("#{}", shown.reference.number)), + ) + .child(div().text_sm().child(title)) + .into_any_element() + } + } + } +} diff --git a/apps/desktop-gpui/src/ui/right_panel.rs b/apps/desktop-gpui/src/ui/right_panel.rs index 9c5f56838a80..75e9eafb70bb 100644 --- a/apps/desktop-gpui/src/ui/right_panel.rs +++ b/apps/desktop-gpui/src/ui/right_panel.rs @@ -1,9 +1,10 @@ //! The web's right panel (`ChatRightPanel.tsx`, `RightPanelTabs.tsx`, `rightPanelStore.ts`) with -//! its Diff surface (`DiffPanel.tsx`). Only that surface is here; files, browser, device, pull -//! requests and side chats have no counterpart yet, so the tab strip lists Diff alone. +//! its Diff (`DiffPanel.tsx`) and pull request surfaces. Files, browser, device and side chats +//! have no counterpart yet, so the tab strip lists Diff and the opened pull requests. use super::*; use crate::diff::{self, Change, Kind, Preview}; +use crate::pull_request::Reference; use gpui::{ScrollStrategy, UniformListScrollHandle, uniform_list}; use std::{ops::Range, rc::Rc}; @@ -32,24 +33,18 @@ const DELETION: u32 = 0xef4444; /// The web's default `rightPanel.toggle` binding on macOS. pub const TOGGLE_SHORTCUT: &str = "\u{2325}\u{2318}B"; -/// What the panel can show; the web's `RightPanelKind` minus the surfaces not ported. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Surface { +/// What the panel shows: the Diff surface or one opened pull request, by its reference key. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum Active { Diff, + PullRequest(String), } -impl Surface { - fn label(self) -> &'static str { - match self { - Self::Diff => "Diff", - } - } - - fn icon(self) -> Icons { - match self { - Self::Diff => Icons::FileDiff, - } - } +/// A `pull-request` tab, the web's `RightPanelSurface` with its reference. +#[derive(Clone)] +pub(super) struct PullRequestTab { + pub reference: Reference, + pub url: Option, } /// The docked width: at least the minimum, at most 70% of the window and what leaves the chat @@ -411,6 +406,67 @@ impl MainView { cx.notify(); } + /// The pull request badge's items: selects the thread, opens its panel on a `pull-request` + /// tab for `reference` and reads it. + pub(super) fn open_pull_request( + &mut self, + thread: String, + reference: Reference, + url: Option, + cx: &mut Context, + ) { + if self.selected_thread.as_deref() != Some(thread.as_str()) { + self.open_thread(Some(thread.clone()), cx); + } + if !self.right_panel_available() { + return; + } + let key = reference.key(); + let tabs = self.pull_request_tabs.entry(thread.clone()).or_default(); + match tabs.iter_mut().find(|tab| tab.reference.key() == key) { + Some(tab) => tab.url = url.or_else(|| tab.url.take()), + None => tabs.push(PullRequestTab { reference, url }), + } + self.right_panel_active + .insert(thread.clone(), Active::PullRequest(key)); + self.right_panel_open.insert(thread); + self.sync_diff(); + cx.notify(); + } + + fn active_surface(&self) -> Active { + let Some(key) = self.details_key() else { + return Active::Diff; + }; + match self.right_panel_active.get(&key) { + Some(Active::PullRequest(reference)) + if self.pull_request_tabs.get(&key).is_some_and(|tabs| { + tabs.iter().any(|tab| tab.reference.key() == *reference) + }) => + { + Active::PullRequest(reference.clone()) + } + _ => Active::Diff, + } + } + + fn select_surface(&mut self, active: Active, cx: &mut Context) { + if let Some(key) = self.details_key() { + self.right_panel_active.insert(key, active); + cx.notify(); + } + } + + fn close_pull_request(&mut self, reference: &str, cx: &mut Context) { + let Some(key) = self.details_key() else { + return; + }; + if let Some(tabs) = self.pull_request_tabs.get_mut(&key) { + tabs.retain(|tab| tab.reference.key() != reference); + } + cx.notify(); + } + /// Asks for the diff of the open panel's directory once per connection. pub(super) fn sync_diff(&mut self) { if !self.right_panel_is_open() { @@ -485,6 +541,16 @@ impl MainView { .and_then(|cwd| self.diffs.get(&cwd).cloned()); self.diff_list .update(cx, |list, cx| list.show(load.as_ref(), cx)); + if let Active::PullRequest(key) = self.active_surface() + && let Some(tab) = self + .details_key() + .and_then(|thread| self.pull_request_tabs.get(&thread)) + .and_then(|tabs| tabs.iter().find(|tab| tab.reference.key() == key)) + .cloned() + { + self.pull_request_panel + .update(cx, |panel, cx| panel.show(tab.reference, tab.url, cx)); + } if is_sheet(window_width) { let width = sheet_width(window_width); return Layout { @@ -505,23 +571,68 @@ impl MainView { fn right_panel_body(&self, width: f32, sheet: bool, cx: &mut Context) -> gpui::Div { let theme = cx.theme(); let (border, foreground) = (theme.border, theme.foreground); - let surface = Surface::Diff; - let tab = h_flex() - .h_6() - .flex_shrink_0() - .gap_1p5() - .pl_1p5() - .pr_2() - .rounded(px(6.)) - .bg(foreground.opacity(0.06)) - .text_xs() - .text_color(foreground) + let active = self.active_surface(); + let tabs = self + .details_key() + .and_then(|key| self.pull_request_tabs.get(&key)) + .cloned() + .unwrap_or_default(); + let tab = |id: gpui::ElementId, icon: Icons, label: String, selected: bool| { + h_flex() + .id(id) + .h_6() + .flex_shrink_0() + .gap_1p5() + .pl_1p5() + .pr_2() + .rounded(px(6.)) + .cursor_pointer() + .text_xs() + .text_color(if selected { + foreground + } else { + theme.muted_foreground + }) + .when(selected, |tab| tab.bg(foreground.opacity(0.06))) + .hover(move |style| style.bg(foreground.opacity(0.06))) + .child( + Icon::new(icon) + .size(px(12.)) + .text_color(theme.muted_foreground), + ) + .child(label) + }; + let diff_tab = tab( + "right-panel-tab-diff".into(), + Icons::FileDiff, + "Diff".to_owned(), + active == Active::Diff, + ) + .on_click(cx.listener(|this, _, _, cx| this.select_surface(Active::Diff, cx))); + let pull_request_tabs = tabs.iter().enumerate().map(|(index, opened)| { + let key = opened.reference.key(); + let selected = active == Active::PullRequest(key.clone()); + let (select, close) = (key.clone(), key); + tab( + ("right-panel-tab-pr", index).into(), + Icons::GitPullRequestArrow, + format!("#{}", opened.reference.number), + selected, + ) + .on_click(cx.listener(move |this, _, _, cx| { + this.select_surface(Active::PullRequest(select.clone()), cx) + })) .child( - Icon::new(surface.icon()) - .size(px(12.)) - .text_color(theme.muted_foreground), + div() + .id(("right-panel-tab-pr-close", index)) + .cursor_pointer() + .child(Icon::new(IconName::Close).size(px(10.))) + .on_click(cx.listener(move |this, _, _, cx| { + cx.stop_propagation(); + this.close_pull_request(&close, cx) + })), ) - .child(surface.label()); + }); let strip = h_flex() .h(px(STRIP_HEIGHT)) .flex_shrink_0() @@ -529,18 +640,21 @@ impl MainView { .gap_1() .border_b_1() .border_color(border) - .child(tab) + .child(diff_tab) + .children(pull_request_tabs) .child(div().flex_1()) - .child( - header_icon_button( - "refresh-diff", - Icons::RefreshCw, - "Refresh diff".into(), - true, - cx, + .when(active == Active::Diff, |strip| { + strip.child( + header_icon_button( + "refresh-diff", + Icons::RefreshCw, + "Refresh diff".into(), + true, + cx, + ) + .on_click(cx.listener(|this, _, _, cx| this.refresh_diff(cx))), ) - .on_click(cx.listener(|this, _, _, cx| this.refresh_diff(cx))), - ) + }) .when(sheet, |strip| { strip.child( ghost_icon_button( @@ -553,6 +667,10 @@ impl MainView { .on_click(cx.listener(|this, _, _, cx| this.toggle_right_panel(cx))), ) }); + let content: AnyElement = match active { + Active::Diff => self.diff_list.clone().into_any_element(), + Active::PullRequest(_) => self.pull_request_panel.clone().into_any_element(), + }; v_flex() .w(px(width)) .h_full() @@ -561,7 +679,7 @@ impl MainView { .bg(cx.theme().background) .when(!sheet, |panel| panel.border_l_1().border_color(border)) .child(strip) - .child(div().flex_1().min_h_0().child(self.diff_list.clone())) + .child(div().flex_1().min_h_0().child(content)) } /// The panel's `DiffPanelShell` as a sheet: a dimmed backdrop and a drawer from the right. From 1297470f62a2bbf1de5b0076a193e97d47a3e3b8 Mon Sep 17 00:00:00 2001 From: Yordis Prieto Date: Mon, 9 Nov 2026 17:22:39 -0500 Subject: [PATCH 71/82] feat(desktop-gpui): pull request detail summary The pull request tab shows the title, state, conflicts, branches, change stats, labels, reviewers, checks and description from the detail read. --- apps/desktop-gpui/src/pull_request.rs | 188 ++++++++++++++ .../desktop-gpui/src/ui/pull_request_panel.rs | 232 ++++++++++++++++-- apps/desktop-gpui/src/ui/right_panel.rs | 2 +- 3 files changed, 406 insertions(+), 16 deletions(-) diff --git a/apps/desktop-gpui/src/pull_request.rs b/apps/desktop-gpui/src/pull_request.rs index ff57d3c17e14..537d427a6bb8 100644 --- a/apps/desktop-gpui/src/pull_request.rs +++ b/apps/desktop-gpui/src/pull_request.rs @@ -2,6 +2,8 @@ //! panel sends and the way a failure is told apart. use crate::rpc::split_not_found; +use crate::status::PrState; +use serde::Deserialize; use serde_json::{Value, json}; /// `PullRequestRef`: the project, the repository on its host and the number. @@ -80,6 +82,151 @@ pub fn detail_failure(message: &str, number: u64) -> Unavailable { } } +/// `PullRequestDetail`, the fields the panel shows. The server's other fields are left out. +#[derive(Clone, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Detail { + pub title: String, + #[serde(default)] + pub body: String, + pub url: String, + #[serde(default)] + pub author: Option, + pub state: State, + #[serde(default)] + pub is_draft: bool, + #[serde(default)] + pub mergeability: Option, + #[serde(default)] + pub additions: u64, + #[serde(default)] + pub deletions: u64, + #[serde(default)] + pub changed_files: u64, + pub head_branch: String, + pub base_branch: String, + #[serde(default)] + pub updated_at: String, + #[serde(default)] + pub reviewers: Vec, + #[serde(default)] + pub labels: Vec