From ae809af85f6687ae21d7e2f7140aa88354c446cc Mon Sep 17 00:00:00 2001 From: main Date: Fri, 20 Mar 2026 21:40:07 -0400 Subject: Add tabbed navigator and managed libgrid UI --- Cargo.lock | 562 ++++++++++++- Cargo.toml | 1 + README.md | 14 + .../systemd/fidget-spinner-libgrid-ui.service.in | 14 + crates/fidget-spinner-cli/Cargo.toml | 1 + crates/fidget-spinner-cli/src/ui.rs | 865 +++++++++++++++++++-- crates/fidget-spinner-cli/tests/mcp_hardening.rs | 1 + crates/fidget-spinner-store-sqlite/src/lib.rs | 67 ++ docs/frontier-membership-design.md | 242 ++++++ scripts/install-local.sh | 82 ++ 10 files changed, 1774 insertions(+), 75 deletions(-) create mode 100644 assets/systemd/fidget-spinner-libgrid-ui.service.in create mode 100644 docs/frontier-membership-design.md diff --git a/Cargo.lock b/Cargo.lock index e26d49f..79a4763 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -64,6 +79,12 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "axum" version = "0.8.8" @@ -116,6 +137,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.0" @@ -128,6 +155,18 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -159,6 +198,19 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.6.0" @@ -199,12 +251,79 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[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-sys" +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", + "core-graphics-types", + "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", + "libc", +] + +[[package]] +name = "core-text" +version = "20.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d2790b5c08465d49f8dc05c8bcae9fea467855947db39b0f8145c091aaced5" +dependencies = [ + "core-foundation", + "core-graphics", + "foreign-types", + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + [[package]] name = "deranged" version = "0.5.8" @@ -247,6 +366,27 @@ dependencies = [ "syn", ] +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[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" @@ -271,6 +411,15 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + [[package]] name = "fidget-spinner-cli" version = "0.1.0" @@ -285,6 +434,7 @@ dependencies = [ "libmcp-testkit", "maud", "percent-encoding", + "plotters", "serde", "serde_json", "time", @@ -323,12 +473,80 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-ord" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ce81f49ae8a0482e4c55ea62ebbd7e5a686af544c00b9d090bba3ff9be97b3d" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "font-kit" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c7e611d49285d4c4b2e1727b72cf05353558885cc5252f93707b845dfcaf3d3" +dependencies = [ + "bitflags 2.11.0", + "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 = "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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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" @@ -338,6 +556,17 @@ 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 = "futures-channel" version = "0.3.32" @@ -395,6 +624,16 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80792593675e051cf94a4b111980da2ba60d4a83e43e0048c5693baab3977045" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -506,6 +745,30 @@ dependencies = [ "tower-service", ] +[[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", +] + +[[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.1.1" @@ -614,6 +877,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "jpeg-decoder", + "num-traits", + "png", +] + [[package]] name = "indexmap" version = "2.13.0" @@ -638,6 +915,12 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jpeg-decoder" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" + [[package]] name = "js-sys" version = "0.3.91" @@ -648,6 +931,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "leb128fmt" version = "0.1.0" @@ -660,6 +949,16 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libmcp" version = "1.1.0" @@ -768,6 +1067,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[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 = "mio" version = "1.1.1" @@ -785,6 +1094,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -803,6 +1121,25 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf9027960355bf3afff9841918474a81a5f972ac6d226d518060bba758b5ad57" +dependencies = [ + "rustc_version", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -827,6 +1164,65 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "chrono", + "font-kit", + "image", + "lazy_static", + "num-traits", + "pathfinder_geometry", + "plotters-backend", + "plotters-bitmap", + "plotters-svg", + "ttf-parser", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-bitmap" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ce181e3f6bf82d6c1dc569103ca7b1bd964c60ba03d7e6cdfbb3e3eb7f7405" +dependencies = [ + "gif", + "image", + "plotters-backend", +] + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[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", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -925,7 +1321,7 @@ version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "165ca6e57b20e1351573e3729b958bc62f0e48025386970b6e4d29e7a7e71f3f" dependencies = [ - "bitflags", + "bitflags 2.11.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -934,6 +1330,15 @@ dependencies = [ "time", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -946,6 +1351,15 @@ 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 = "schemars" version = "1.2.1" @@ -1060,6 +1474,12 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + [[package]] name = "slab" version = "0.4.12" @@ -1257,6 +1677,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "ttf-parser" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17f77d76d837a7830fe1d4f12b7b4ba4192c1888001c7164257e4bc6d21d96b4" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1317,6 +1743,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[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 = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1414,18 +1850,118 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.0", "hashbrown 0.15.5", "indexmap", "semver", ] +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[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", +] + +[[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-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[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", +] + +[[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", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -1435,6 +1971,15 @@ dependencies = [ "windows-link", ] +[[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.51.0" @@ -1493,7 +2038,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.0", "indexmap", "log", "serde", @@ -1529,6 +2074,17 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503a066b4c037c440169d995b869046827dbc71263f6e8f3be6d77d4f3229dbd" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" version = "0.8.1" diff --git a/Cargo.toml b/Cargo.toml index 01bd62a..4b626c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ dirs = "6" linkify = "0.10" maud = { version = "0.27", features = ["axum"] } percent-encoding = "2" +plotters = "0.3.7" rusqlite = { version = "0.37", features = ["bundled", "time"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1" diff --git a/README.md b/README.md index b2bd6cd..0277361 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,20 @@ symlinks in `~/.codex/skills`: The installed binary is `~/.local/bin/fidget-spinner-cli`. +The installer also installs a user systemd service for the libgrid navigator at +`http://127.0.0.1:8913/` and refreshes it on every reinstall: + +```bash +systemctl --user status fidget-spinner-libgrid-ui.service +journalctl --user -u fidget-spinner-libgrid-ui.service -f +``` + +You can override the default service target for one install with: + +```bash +FIDGET_SPINNER_UI_PROJECT=/abs/path/to/project ./scripts/install-local.sh +``` + ## Quickstart Initialize a project: diff --git a/assets/systemd/fidget-spinner-libgrid-ui.service.in b/assets/systemd/fidget-spinner-libgrid-ui.service.in new file mode 100644 index 0000000..eddfab6 --- /dev/null +++ b/assets/systemd/fidget-spinner-libgrid-ui.service.in @@ -0,0 +1,14 @@ +[Unit] +Description=Fidget Spinner libgrid navigator + +[Service] +Type=simple +WorkingDirectory=@UI_PROJECT_ROOT@ +Environment=HOME=@HOME@ +Environment=PATH=@HOME@/.cargo/bin:@LOCAL_BIN_DIR@:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +ExecStart=@LOCAL_BIN_DIR@/fidget-spinner-cli ui serve --path @UI_PROJECT_ROOT@ --bind @UI_BIND@ +Restart=always +RestartSec=3 + +[Install] +WantedBy=default.target diff --git a/crates/fidget-spinner-cli/Cargo.toml b/crates/fidget-spinner-cli/Cargo.toml index a9e76d4..69f72b2 100644 --- a/crates/fidget-spinner-cli/Cargo.toml +++ b/crates/fidget-spinner-cli/Cargo.toml @@ -21,6 +21,7 @@ fidget-spinner-store-sqlite = { path = "../fidget-spinner-store-sqlite" } libmcp = { git = "https://git.swarm.moe/libmcp.git", rev = "bb92a05eb5446e07c6288e266bd06d7b5899eee5" } maud.workspace = true percent-encoding.workspace = true +plotters.workspace = true serde.workspace = true serde_json.workspace = true time.workspace = true diff --git a/crates/fidget-spinner-cli/src/ui.rs b/crates/fidget-spinner-cli/src/ui.rs index 8eb1845..d88bab0 100644 --- a/crates/fidget-spinner-cli/src/ui.rs +++ b/crates/fidget-spinner-cli/src/ui.rs @@ -2,21 +2,28 @@ use std::io; use std::net::SocketAddr; use axum::Router; -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::response::{Html, IntoResponse, Response}; use axum::routing::get; use camino::Utf8PathBuf; use fidget_spinner_core::{ AttachmentTargetRef, ExperimentAnalysis, ExperimentOutcome, ExperimentStatus, FrontierRecord, - FrontierVerdict, MetricUnit, RunDimensionValue, Slug, VertexRef, + FrontierVerdict, MetricUnit, NonEmptyText, RunDimensionValue, Slug, VertexRef, }; use fidget_spinner_store_sqlite::{ - ExperimentDetail, ExperimentSummary, FrontierOpenProjection, FrontierSummary, - HypothesisCurrentState, HypothesisDetail, ProjectStatus, StoreError, VertexSummary, + ExperimentDetail, ExperimentSummary, FrontierMetricSeries, FrontierOpenProjection, + FrontierSummary, HypothesisCurrentState, HypothesisDetail, ListExperimentsQuery, + ListHypothesesQuery, MetricKeysQuery, MetricScope, ProjectStatus, StoreError, VertexSummary, }; use maud::{DOCTYPE, Markup, PreEscaped, html}; use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode}; +use plotters::prelude::{ + BLACK, ChartBuilder, Circle, IntoDrawingArea, LineSeries, PathElement, SVGBackend, ShapeStyle, + Text, +}; +use plotters::style::{Color, IntoFont, RGBColor}; +use serde::Deserialize; use time::OffsetDateTime; use time::format_description::well_known::Rfc3339; use time::macros::format_description; @@ -29,6 +36,27 @@ struct NavigatorState { limit: Option, } +#[derive(Clone)] +struct ShellFrame { + active_frontier_slug: Option, + frontiers: Vec, + project_status: ProjectStatus, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum FrontierTab { + Brief, + Open, + Closed, + Metrics, +} + +#[derive(Clone, Debug, Default, Deserialize)] +struct FrontierPageQuery { + metric: Option, + tab: Option, +} + struct AttachmentDisplay { kind: &'static str, href: String, @@ -36,6 +64,35 @@ struct AttachmentDisplay { summary: Option, } +impl FrontierTab { + fn from_query(raw: Option<&str>) -> Self { + match raw { + Some("open") => Self::Open, + Some("closed") => Self::Closed, + Some("metrics") => Self::Metrics, + _ => Self::Brief, + } + } + + const fn as_query(self) -> &'static str { + match self { + Self::Brief => "brief", + Self::Open => "open", + Self::Closed => "closed", + Self::Metrics => "metrics", + } + } + + const fn label(self) -> &'static str { + match self { + Self::Brief => "Brief", + Self::Open => "Open", + Self::Closed => "Closed", + Self::Metrics => "Metrics", + } + } +} + pub(crate) fn serve( project_root: Utf8PathBuf, bind: SocketAddr, @@ -74,8 +131,9 @@ async fn project_home(State(state): State) -> Response { async fn frontier_detail( State(state): State, Path(selector): Path, + Query(query): Query, ) -> Response { - render_response(render_frontier_detail(state, selector)) + render_response(render_frontier_detail(state, selector, query)) } async fn hypothesis_detail( @@ -118,50 +176,65 @@ fn render_response(result: Result) -> Response { fn render_project_home(state: NavigatorState) -> Result { let store = open_store(state.project_root.as_std_path())?; - let project_status = store.status()?; - let frontiers = store.list_frontiers()?; - let title = format!("{} navigator", project_status.display_name); + let shell = load_shell_frame(&store, None)?; + let title = format!("{} navigator", shell.project_status.display_name); let content = html! { - (render_project_status(&project_status)) - (render_frontier_grid(&frontiers, state.limit)) + (render_project_status(&shell.project_status)) + (render_frontier_grid(&shell.frontiers, state.limit)) }; Ok(render_shell( &title, - Some(&project_status.display_name.to_string()), + &shell, + true, + Some(&shell.project_status.display_name.to_string()), + None, None, content, )) } -fn render_frontier_detail(state: NavigatorState, selector: String) -> Result { +fn render_frontier_detail( + state: NavigatorState, + selector: String, + query: FrontierPageQuery, +) -> Result { let store = open_store(state.project_root.as_std_path())?; let projection = store.frontier_open(&selector)?; + let shell = load_shell_frame(&store, Some(projection.frontier.slug.clone()))?; + let tab = FrontierTab::from_query(query.tab.as_deref()); let title = format!("{} · frontier", projection.frontier.label); let subtitle = format!( "{} hypotheses active · {} experiments open", projection.active_hypotheses.len(), projection.open_experiments.len() ); - let content = html! { - (render_frontier_header(&projection.frontier)) - (render_frontier_brief(&projection)) - (render_frontier_active_sets(&projection)) - (render_hypothesis_current_state_grid( - &projection.active_hypotheses, - state.limit, - )) - (render_open_experiment_grid( - &projection.open_experiments, - state.limit, - )) - }; - Ok(render_shell(&title, Some(&subtitle), None, content)) + let content = render_frontier_tab_content( + &store, + &projection, + tab, + query.metric.as_deref(), + state.limit, + )?; + Ok(render_shell( + &title, + &shell, + false, + Some(&subtitle), + None, + Some(render_frontier_tab_bar( + &projection.frontier.slug, + tab, + query.metric.as_deref(), + )), + content, + )) } fn render_hypothesis_detail(state: NavigatorState, selector: String) -> Result { let store = open_store(state.project_root.as_std_path())?; let detail = store.read_hypothesis(&selector)?; let frontier = store.read_frontier(&detail.record.frontier_id.to_string())?; + let shell = load_shell_frame(&store, Some(frontier.slug.clone()))?; let title = format!("{} · hypothesis", detail.record.title); let subtitle = detail.record.summary.to_string(); let content = html! { @@ -182,8 +255,11 @@ fn render_hypothesis_detail(state: NavigatorState, selector: String) -> Result Result Result Result Result { let store = open_store(state.project_root.as_std_path())?; let detail = store.read_artifact(&selector)?; + let shell = load_shell_frame(&store, None)?; let attachments = detail .attachments .iter() @@ -263,7 +344,411 @@ fn render_artifact_detail(state: NavigatorState, selector: String) -> Result, +) -> Result { + Ok(ShellFrame { + active_frontier_slug, + frontiers: store.list_frontiers()?, + project_status: store.status()?, + }) +} + +fn render_frontier_tab_content( + store: &fidget_spinner_store_sqlite::ProjectStore, + projection: &FrontierOpenProjection, + tab: FrontierTab, + metric_selector: Option<&str>, + limit: Option, +) -> Result { + match tab { + FrontierTab::Brief => Ok(html! { + (render_frontier_header(&projection.frontier)) + (render_frontier_brief(projection)) + (render_frontier_active_sets(projection)) + }), + FrontierTab::Open => Ok(html! { + (render_frontier_header(&projection.frontier)) + (render_hypothesis_current_state_grid(&projection.active_hypotheses, limit)) + (render_open_experiment_grid(&projection.open_experiments, limit)) + }), + FrontierTab::Closed => { + let closed_hypotheses = store + .list_hypotheses(ListHypothesesQuery { + frontier: Some(projection.frontier.slug.to_string()), + limit: None, + ..ListHypothesesQuery::default() + })? + .into_iter() + .filter(|hypothesis| hypothesis.open_experiment_count == 0) + .collect::>(); + let closed_experiments = store.list_experiments(ListExperimentsQuery { + frontier: Some(projection.frontier.slug.to_string()), + status: Some(ExperimentStatus::Closed), + limit: None, + ..ListExperimentsQuery::default() + })?; + Ok(html! { + (render_frontier_header(&projection.frontier)) + (render_closed_hypothesis_grid(&closed_hypotheses, limit)) + (render_experiment_section("Closed Experiments", &closed_experiments, limit)) + }) + } + FrontierTab::Metrics => { + let metric_keys = if projection.active_metric_keys.is_empty() { + store.metric_keys(MetricKeysQuery { + frontier: Some(projection.frontier.slug.to_string()), + scope: MetricScope::Visible, + })? + } else { + projection.active_metric_keys.clone() + }; + let selected_metric = metric_selector + .and_then(|selector| NonEmptyText::new(selector.to_owned()).ok()) + .or_else(|| metric_keys.first().map(|metric| metric.key.clone())); + let series = selected_metric + .as_ref() + .map(|metric| { + store.frontier_metric_series(projection.frontier.slug.as_str(), metric, true) + }) + .transpose()?; + Ok(html! { + (render_frontier_header(&projection.frontier)) + (render_metric_series_section( + &projection.frontier.slug, + &metric_keys, + selected_metric.as_ref(), + series.as_ref(), + limit, + )) + }) + } + } +} + +fn render_frontier_tab_bar( + frontier_slug: &Slug, + active_tab: FrontierTab, + metric: Option<&str>, +) -> Markup { + const TABS: [FrontierTab; 4] = [ + FrontierTab::Brief, + FrontierTab::Open, + FrontierTab::Closed, + FrontierTab::Metrics, + ]; + html! { + nav.tab-row aria-label="Frontier tabs" { + @for tab in TABS { + @let href = frontier_tab_href(frontier_slug, tab, metric); + a + href=(href) + class={(if tab == active_tab { "tab-chip active" } else { "tab-chip" })} + { + (tab.label()) + } + } + } + } +} + +fn render_closed_hypothesis_grid( + hypotheses: &[fidget_spinner_store_sqlite::HypothesisSummary], + limit: Option, +) -> Markup { + html! { + section.card { + h2 { "Closed Hypotheses" } + @if hypotheses.is_empty() { + p.muted { "No dormant hypotheses yet." } + } @else { + div.card-grid { + @for hypothesis in limit_items(hypotheses, limit) { + article.mini-card { + div.card-header { + a.title-link href=(hypothesis_href(&hypothesis.slug)) { + (hypothesis.title) + } + @if let Some(verdict) = hypothesis.latest_verdict { + span class=(status_chip_classes(verdict_class(verdict))) { + (verdict.as_str()) + } + } + } + p.prose { (hypothesis.summary) } + @if !hypothesis.tags.is_empty() { + div.chip-row { + @for tag in &hypothesis.tags { + span.tag-chip { (tag) } + } + } + } + div.meta-row.muted { + span { "updated " (format_timestamp(hypothesis.updated_at)) } + } + } + } + } + } + } + } +} + +fn render_metric_series_section( + frontier_slug: &Slug, + metric_keys: &[fidget_spinner_store_sqlite::MetricKeySummary], + selected_metric: Option<&NonEmptyText>, + series: Option<&FrontierMetricSeries>, + limit: Option, +) -> Markup { + html! { + section.card { + h2 { "Metrics" } + p.prose { + "Server-rendered SVG over the frontier’s closed experiment ledger. Choose a live metric, then walk to the underlying experiments deliberately." + } + @if metric_keys.is_empty() { + p.muted { "No visible metrics registered for this frontier." } + } @else { + div.metric-picker { + @for metric in metric_keys { + @let href = frontier_tab_href(frontier_slug, FrontierTab::Metrics, Some(metric.key.as_str())); + a + href=(href) + class={(if selected_metric.is_some_and(|selected| selected == &metric.key) { + "metric-choice active" + } else { + "metric-choice" + })} + { + span.metric-choice-key { (metric.key) } + span.metric-choice-meta { + (metric.objective.as_str()) " · " + (metric.unit.as_str()) + } + } + } + } + } + } + @if let Some(series) = series { + section.card { + div.card-header { + h2 { "Plot" } + span.metric-pill { + (series.metric.key) " · " + (series.metric.objective.as_str()) " · " + (series.metric.unit.as_str()) + } + } + @if let Some(description) = series.metric.description.as_ref() { + p.muted { (description) } + } + @if series.points.is_empty() { + p.muted { "No closed experiments for this metric yet." } + } @else { + div.chart-frame { + (PreEscaped(render_metric_chart_svg(series))) + } + p.muted { + "x = close order, y = metric value. Point color tracks verdict." + } + table.metric-table { + thead { + tr { + th { "#" } + th { "Experiment" } + th { "Hypothesis" } + th { "Closed" } + th { "Verdict" } + th { "Value" } + } + } + tbody { + @for (index, point) in limit_items(&series.points, limit).iter().enumerate() { + tr { + td { ((index + 1).to_string()) } + td { + a href=(experiment_href(&point.experiment.slug)) { + (point.experiment.title) + } + } + td { + a href=(hypothesis_href(&point.hypothesis.slug)) { + (point.hypothesis.title) + } + } + td { (format_timestamp(point.closed_at)) } + td { + span class=(status_chip_classes(verdict_class(point.verdict))) { + (point.verdict.as_str()) + } + } + td { (format_metric_value(point.value, series.metric.unit)) } + } + } + } + } + } + } + } + } +} + +fn render_metric_chart_svg(series: &FrontierMetricSeries) -> String { + let mut svg = String::new(); + { + let root = SVGBackend::with_string(&mut svg, (960, 360)).into_drawing_area(); + if root.fill(&RGBColor(255, 250, 242)).is_err() { + return chart_error_markup("chart fill failed"); + } + let values = series + .points + .iter() + .map(|point| point.value) + .collect::>(); + let (mut min_value, mut max_value) = values + .iter() + .copied() + .fold((f64::INFINITY, f64::NEG_INFINITY), |(min, max), value| { + (min.min(value), max.max(value)) + }); + if !min_value.is_finite() || !max_value.is_finite() { + return chart_error_markup("metric values are non-finite"); + } + if (max_value - min_value).abs() < f64::EPSILON { + let pad = if max_value.abs() < 1.0 { + 1.0 + } else { + max_value.abs() * 0.05 + }; + min_value -= pad; + max_value += pad; + } else { + let pad = (max_value - min_value) * 0.08; + min_value -= pad; + max_value += pad; + } + let x_end = i32::try_from(series.points.len().saturating_sub(1)) + .unwrap_or(0) + .max(1); + let mut chart = match ChartBuilder::on(&root) + .margin(18) + .x_label_area_size(32) + .y_label_area_size(72) + .caption( + format!("{} over closed experiments", series.metric.key), + ("Iosevka Web", 18).into_font().color(&BLACK), + ) + .build_cartesian_2d(0_i32..x_end, min_value..max_value) + { + Ok(chart) => chart, + Err(error) => return chart_error_markup(&format!("chart build failed: {error:?}")), + }; + if chart + .configure_mesh() + .light_line_style(RGBColor(223, 209, 189).mix(0.6)) + .bold_line_style(RGBColor(207, 190, 168).mix(0.8)) + .axis_style(RGBColor(103, 86, 63)) + .label_style(("Iosevka Web", 12).into_font().color(&RGBColor(79, 71, 58))) + .x_desc("close order") + .y_desc(series.metric.unit.as_str()) + .x_label_formatter(&|value| format!("{}", value + 1)) + .draw() + .is_err() + { + return chart_error_markup("mesh draw failed"); + } + + let line_points = series + .points + .iter() + .enumerate() + .filter_map(|(index, point)| i32::try_from(index).ok().map(|x| (x, point.value))) + .collect::>(); + if chart + .draw_series(LineSeries::new(line_points, &RGBColor(103, 86, 63))) + .map(|series| { + series.label("series").legend(|(x, y)| { + PathElement::new(vec![(x, y), (x + 18, y)], RGBColor(103, 86, 63)) + }) + }) + .is_err() + { + return chart_error_markup("line draw failed"); + } + + let points = series + .points + .iter() + .enumerate() + .filter_map(|(index, point)| i32::try_from(index).ok().map(|x| (x, point))) + .collect::>(); + if chart + .draw_series(points.iter().map(|(x, point)| { + Circle::new( + (*x, point.value), + 4, + ShapeStyle::from(&verdict_color(point.verdict)).filled(), + ) + })) + .is_err() + { + return chart_error_markup("point draw failed"); + } + if chart + .draw_series(points.iter().map(|(x, point)| { + Text::new( + format!("{}", x + 1), + (*x, point.value), + ("Iosevka Web", 11) + .into_font() + .color(&verdict_color(point.verdict)), + ) + })) + .is_err() + { + return chart_error_markup("label draw failed"); + } + if root.present().is_err() { + return chart_error_markup("chart present failed"); + } + } + svg +} + +fn chart_error_markup(message: &str) -> String { + format!( + "
chart render failed: {}
", + html_escape(message) + ) +} + +fn html_escape(raw: &str) -> String { + raw.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn verdict_color(verdict: FrontierVerdict) -> RGBColor { + match verdict { + FrontierVerdict::Accepted => RGBColor(71, 102, 63), + FrontierVerdict::Kept => RGBColor(90, 105, 82), + FrontierVerdict::Parked => RGBColor(138, 98, 48), + FrontierVerdict::Rejected => RGBColor(138, 58, 52), + } } fn render_frontier_grid(frontiers: &[FrontierSummary], limit: Option) -> Markup { @@ -354,16 +839,15 @@ fn render_frontier_brief(projection: &FrontierOpenProjection) -> Markup { p.muted { "No roadmap ordering recorded." } } @else { ol.roadmap-list { - @for item in &frontier.brief.roadmap { - @let title = hypothesis_title_for_roadmap_item(projection, item.hypothesis_id); - li { - a href=(hypothesis_href_from_id(item.hypothesis_id)) { - (format!("{}.", item.rank)) " " - (title) - } - @if let Some(summary) = item.summary.as_ref() { - span.muted { " · " (summary) } - } + @for item in &frontier.brief.roadmap { + @let title = hypothesis_title_for_roadmap_item(projection, item.hypothesis_id); + li { + a href=(hypothesis_href_from_id(item.hypothesis_id)) { + (title) + } + @if let Some(summary) = item.summary.as_ref() { + span.muted { " · " (summary) } + } } } } @@ -390,13 +874,13 @@ fn render_frontier_active_sets(projection: &FrontierOpenProjection) -> Markup { html! { section.card { h2 { "Active Surface" } - div.split { - div.subcard { + div.stack { + div.subcard.compact-subcard { h3 { "Active Tags" } @if projection.active_tags.is_empty() { p.muted { "No active tags." } } @else { - div.chip-row { + div.chip-row.tag-cloud { @for tag in &projection.active_tags { span.tag-chip { (tag) } } @@ -420,7 +904,15 @@ fn render_frontier_active_sets(projection: &FrontierOpenProjection) -> Markup { tbody { @for metric in &projection.active_metric_keys { tr { - td { (metric.key) } + td { + a href=(frontier_tab_href( + &projection.frontier.slug, + FrontierTab::Metrics, + Some(metric.key.as_str()), + )) { + (metric.key) + } + } td { (metric.unit.as_str()) } td { (metric.objective.as_str()) } td { (metric.reference_count) } @@ -914,8 +1406,11 @@ fn render_prose_block(title: &str, body: &str) -> Markup { fn render_shell( title: &str, + shell: &ShellFrame, + show_page_header: bool, subtitle: Option<&str>, breadcrumb: Option<(&str, String)>, + tab_bar: Option, content: Markup, ) -> Markup { html! { @@ -929,24 +1424,76 @@ fn render_shell( } body { main.shell { - header.page-header { - div.eyebrow { - a href="/" { "home" } - @if let Some((label, href)) = breadcrumb { - span.sep { "/" } - a href=(href) { (label) } + aside.sidebar { + (render_sidebar(shell)) + } + div.main-column { + @if show_page_header { + header.page-header { + div.eyebrow { + a href="/" { "home" } + @if let Some((label, href)) = breadcrumb { + span.sep { "/" } + a href=(href) { (label) } + } + } + h1.page-title { (title) } + @if let Some(subtitle) = subtitle { + p.page-subtitle { (subtitle) } + } } } - h1.page-title { (title) } - @if let Some(subtitle) = subtitle { - p.page-subtitle { (subtitle) } + @if let Some(tab_bar) = tab_bar { + (tab_bar) + } + (content) + } + } + } + } + } +} + +fn render_sidebar(shell: &ShellFrame) -> Markup { + html! { + section.sidebar-panel { + div.sidebar-project { + a.sidebar-home href="/" { (&shell.project_status.display_name) } + p.sidebar-copy { + "Frontier-scoped navigator. Open one frontier, then walk hypotheses and experiments deliberately." + } + } + div.sidebar-section { + h2 { "Frontiers" } + @if shell.frontiers.is_empty() { + p.muted { "No frontiers yet." } + } @else { + nav.frontier-nav aria-label="Frontiers" { + @for frontier in &shell.frontiers { + a + href=(frontier_href(&frontier.slug)) + class={(if shell + .active_frontier_slug + .as_ref() + .is_some_and(|active| active == &frontier.slug) + { + "frontier-nav-link active" + } else { + "frontier-nav-link" + })} + { + span.frontier-nav-title { (&frontier.label) } + span.frontier-nav-meta { + (frontier.active_hypothesis_count) " active · " + (frontier.open_experiment_count) " open" + } } } - (content) } } } } + } } fn render_kv(label: &str, value: &str) -> Markup { @@ -1017,6 +1564,19 @@ fn frontier_href(slug: &Slug) -> String { format!("/frontier/{}", encode_path_segment(slug.as_str())) } +fn frontier_tab_href(slug: &Slug, tab: FrontierTab, metric: Option<&str>) -> String { + let mut href = format!( + "/frontier/{}?tab={}", + encode_path_segment(slug.as_str()), + tab.as_query() + ); + if let Some(metric) = metric.filter(|metric| !metric.trim().is_empty()) { + href.push_str("&metric="); + href.push_str(&encode_path_segment(metric)); + } + href +} + fn hypothesis_href(slug: &Slug) -> String { format!("/hypothesis/{}", encode_path_segment(slug.as_str())) } @@ -1128,21 +1688,21 @@ fn styles() -> &'static str { r#" :root { color-scheme: light; - --bg: #f6f3ec; - --panel: #fffdf8; - --panel-2: #f3eee4; - --border: #d8d1c4; - --border-strong: #c8bfaf; - --text: #22201a; - --muted: #746e62; - --accent: #2d5c4d; - --accent-soft: #dbe8e2; - --tag: #ece5d8; - --accepted: #2f6b43; - --kept: #3d6656; - --parked: #8b5b24; - --rejected: #8a2f2f; - --shadow: rgba(74, 58, 32, 0.06); + --bg: #faf5ec; + --panel: #fffaf2; + --panel-2: #f6eee1; + --border: #dfd1bd; + --border-strong: #cfbea8; + --text: #241d16; + --muted: #6f6557; + --accent: #67563f; + --accent-soft: #ece2d2; + --tag: #efe5d7; + --accepted: #47663f; + --kept: #5a6952; + --parked: #8a6230; + --rejected: #8a3a34; + --shadow: rgba(83, 61, 33, 0.055); } * { box-sizing: border-box; } body { @@ -1150,6 +1710,7 @@ fn styles() -> &'static str { background: var(--bg); color: var(--text); font: 15px/1.55 "Iosevka Web", "IBM Plex Mono", "SFMono-Regular", monospace; + overflow-x: hidden; } a { color: var(--accent); @@ -1160,8 +1721,72 @@ fn styles() -> &'static str { width: min(1360px, 100%); margin: 0 auto; padding: 24px 24px 40px; + display: grid; + gap: 20px; + grid-template-columns: 280px minmax(0, 1fr); + align-items: start; + min-width: 0; + overflow-x: clip; + } + .sidebar { + position: sticky; + top: 18px; + min-width: 0; + } + .sidebar-panel { + border: 1px solid var(--border); + background: var(--panel); + padding: 18px 16px; + display: grid; + gap: 16px; + box-shadow: 0 1px 0 var(--shadow); + } + .sidebar-project { + display: grid; + gap: 8px; + } + .sidebar-home { + color: var(--text); + font-size: 18px; + font-weight: 700; + } + .sidebar-copy { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.5; + } + .sidebar-section { + display: grid; + gap: 10px; + } + .frontier-nav { + display: grid; + gap: 8px; + } + .frontier-nav-link { + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid var(--border); + background: var(--panel-2); + } + .frontier-nav-link.active { + border-color: var(--border-strong); + background: var(--accent-soft); + } + .frontier-nav-title { + color: var(--text); + font-weight: 700; + } + .frontier-nav-meta { + color: var(--muted); + font-size: 12px; + } + .main-column { display: grid; gap: 18px; + min-width: 0; } .page-header { display: grid; @@ -1170,6 +1795,7 @@ fn styles() -> &'static str { border: 1px solid var(--border); background: var(--panel); box-shadow: 0 1px 0 var(--shadow); + min-width: 0; } .eyebrow { display: flex; @@ -1186,6 +1812,7 @@ fn styles() -> &'static str { font-size: clamp(22px, 3.8vw, 34px); line-height: 1.1; overflow-wrap: anywhere; + word-break: break-word; } .page-subtitle { margin: 0; @@ -1193,6 +1820,28 @@ fn styles() -> &'static str { max-width: 90ch; overflow-wrap: anywhere; } + .tab-row { + display: flex; + flex-wrap: wrap; + gap: 10px; + } + .tab-chip { + display: inline-flex; + align-items: center; + padding: 8px 12px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--muted); + font-size: 13px; + text-transform: uppercase; + letter-spacing: 0.05em; + } + .tab-chip.active { + color: var(--text); + border-color: var(--border-strong); + background: var(--accent-soft); + font-weight: 700; + } .card { border: 1px solid var(--border); background: var(--panel); @@ -1200,6 +1849,7 @@ fn styles() -> &'static str { display: grid; gap: 14px; box-shadow: 0 1px 0 var(--shadow); + min-width: 0; } .subcard { border: 1px solid var(--border); @@ -1210,7 +1860,14 @@ fn styles() -> &'static str { min-width: 0; align-content: start; } + .compact-subcard { + justify-items: start; + } .block { display: grid; gap: 10px; } + .stack { + display: grid; + gap: 14px; + } .split { display: grid; gap: 16px; @@ -1243,10 +1900,16 @@ fn styles() -> &'static str { font-weight: 700; color: var(--text); overflow-wrap: anywhere; + word-break: break-word; + flex: 1 1 auto; + min-width: 0; } h1, h2, h3 { margin: 0; line-height: 1.15; + overflow-wrap: anywhere; + word-break: break-word; + min-width: 0; } h2 { font-size: 19px; } h3 { font-size: 14px; color: #4f473a; } @@ -1288,17 +1951,49 @@ fn styles() -> &'static str { flex-wrap: wrap; gap: 8px; align-items: flex-start; + align-content: flex-start; + justify-content: flex-start; } + .tag-cloud { max-width: 100%; } .tag-chip, .kind-chip, .status-chip, .metric-pill { display: inline-flex; align-items: center; - width: fit-content; + flex: 0 0 auto; + width: auto; max-width: 100%; border: 1px solid var(--border-strong); background: var(--tag); padding: 4px 8px; font-size: 12px; line-height: 1.2; + white-space: nowrap; + } + .metric-picker { + display: flex; + flex-wrap: wrap; + gap: 10px; + } + .metric-choice { + display: grid; + gap: 4px; + padding: 10px 12px; + border: 1px solid var(--border); + background: var(--panel-2); + min-width: 0; + } + .metric-choice.active { + border-color: var(--border-strong); + background: var(--accent-soft); + } + .metric-choice-key { + color: var(--text); + font-weight: 700; + } + .metric-choice-meta { + color: var(--muted); + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.05em; } .link-chip { display: inline-grid; @@ -1336,17 +2031,19 @@ fn styles() -> &'static str { letter-spacing: 0.05em; font-weight: 700; } - .status-accepted { color: var(--accepted); border-color: rgba(47, 107, 67, 0.25); background: rgba(47, 107, 67, 0.08); } - .status-kept { color: var(--kept); border-color: rgba(61, 102, 86, 0.25); background: rgba(61, 102, 86, 0.08); } - .status-parked { color: var(--parked); border-color: rgba(139, 91, 36, 0.25); background: rgba(139, 91, 36, 0.09); } - .status-rejected { color: var(--rejected); border-color: rgba(138, 47, 47, 0.25); background: rgba(138, 47, 47, 0.09); } - .status-open, .status-exploring { color: var(--accent); border-color: rgba(45, 92, 77, 0.25); background: var(--accent-soft); } + .status-accepted { color: var(--accepted); border-color: color-mix(in srgb, var(--accepted) 24%, white); background: color-mix(in srgb, var(--accepted) 10%, white); } + .status-kept { color: var(--kept); border-color: color-mix(in srgb, var(--kept) 22%, white); background: color-mix(in srgb, var(--kept) 9%, white); } + .status-parked { color: var(--parked); border-color: color-mix(in srgb, var(--parked) 24%, white); background: color-mix(in srgb, var(--parked) 10%, white); } + .status-rejected { color: var(--rejected); border-color: color-mix(in srgb, var(--rejected) 24%, white); background: color-mix(in srgb, var(--rejected) 10%, white); } + .status-open, .status-exploring { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 22%, white); background: var(--accent-soft); } .status-neutral, .classless { color: #5f584d; border-color: var(--border-strong); background: var(--panel); } .status-archived { color: #7a756d; border-color: var(--border); background: var(--panel); } .metric-table { width: 100%; border-collapse: collapse; font-size: 13px; + display: block; + overflow-x: auto; } .metric-table th, .metric-table td { @@ -1354,6 +2051,7 @@ fn styles() -> &'static str { border-top: 1px solid var(--border); text-align: left; vertical-align: top; + overflow-wrap: anywhere; } .metric-table th { color: var(--muted); @@ -1366,6 +2064,21 @@ fn styles() -> &'static str { display: grid; gap: 8px; } + .chart-frame { + border: 1px solid var(--border); + background: var(--panel-2); + padding: 10px; + overflow-x: auto; + } + .chart-frame svg { + display: block; + width: 100%; + height: auto; + } + .chart-error { + color: var(--rejected); + font-size: 13px; + } .roadmap-list, .simple-list { margin: 0; padding-left: 18px; @@ -1388,6 +2101,14 @@ fn styles() -> &'static str { background: var(--panel-2); padding: 0.05rem 0.3rem; } + @media (max-width: 980px) { + .shell { + grid-template-columns: 1fr; + } + .sidebar { + position: static; + } + } @media (max-width: 720px) { .shell { padding: 12px; } .card, .page-header { padding: 14px; } diff --git a/crates/fidget-spinner-cli/tests/mcp_hardening.rs b/crates/fidget-spinner-cli/tests/mcp_hardening.rs index 37e2558..c4ee002 100644 --- a/crates/fidget-spinner-cli/tests/mcp_hardening.rs +++ b/crates/fidget-spinner-cli/tests/mcp_hardening.rs @@ -13,6 +13,7 @@ use libmcp as _; use libmcp_testkit::assert_no_opaque_ids; use maud as _; use percent_encoding as _; +use plotters as _; use serde as _; use serde_json::{Value, json}; use time as _; diff --git a/crates/fidget-spinner-store-sqlite/src/lib.rs b/crates/fidget-spinner-store-sqlite/src/lib.rs index 3680471..283f5d3 100644 --- a/crates/fidget-spinner-store-sqlite/src/lib.rs +++ b/crates/fidget-spinner-store-sqlite/src/lib.rs @@ -490,6 +490,22 @@ pub struct FrontierOpenProjection { pub open_experiments: Vec, } +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct FrontierMetricPoint { + pub experiment: ExperimentSummary, + pub hypothesis: HypothesisSummary, + pub value: f64, + pub verdict: FrontierVerdict, + pub closed_at: OffsetDateTime, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +pub struct FrontierMetricSeries { + pub frontier: FrontierRecord, + pub metric: MetricKeySummary, + pub points: Vec, +} + pub struct ProjectStore { project_root: Utf8PathBuf, state_root: Utf8PathBuf, @@ -1381,6 +1397,57 @@ impl ProjectStore { }) } + pub fn frontier_metric_series( + &self, + frontier: &str, + key: &NonEmptyText, + include_rejected: bool, + ) -> Result { + let frontier = self.resolve_frontier(frontier)?; + let definition = self + .metric_definition(key)? + .ok_or_else(|| StoreError::UnknownMetricDefinition(key.clone()))?; + let mut points = self + .load_experiment_records(Some(frontier.id), None, true)? + .into_iter() + .filter(|record| record.status == ExperimentStatus::Closed) + .filter_map(|record| { + let outcome = record.outcome.clone()?; + if !include_rejected && outcome.verdict == FrontierVerdict::Rejected { + return None; + } + let metric = all_metrics(&outcome) + .into_iter() + .find(|metric| metric.key == *key)?; + Some((record, outcome, metric.value)) + }) + .map(|(record, outcome, value)| { + Ok(FrontierMetricPoint { + closed_at: outcome.closed_at, + experiment: self.experiment_summary_from_record(record.clone())?, + hypothesis: self.hypothesis_summary_from_record( + self.hypothesis_by_id(record.hypothesis_id)?, + )?, + value, + verdict: outcome.verdict, + }) + }) + .collect::, StoreError>>()?; + points.sort_by_key(|point| point.closed_at); + Ok(FrontierMetricSeries { + metric: MetricKeySummary { + key: definition.key.clone(), + unit: definition.unit, + objective: definition.objective, + visibility: definition.visibility, + description: definition.description, + reference_count: self.metric_reference_count(Some(frontier.id), key)?, + }, + frontier, + points, + }) + } + pub fn metric_keys(&self, query: MetricKeysQuery) -> Result, StoreError> { let frontier_id = query .frontier diff --git a/docs/frontier-membership-design.md b/docs/frontier-membership-design.md new file mode 100644 index 0000000..3bccf49 --- /dev/null +++ b/docs/frontier-membership-design.md @@ -0,0 +1,242 @@ +# Frontier Membership Without Partitioning + +## Status + +Prospective design note only. Do not implement yet. + +The current single-frontier posture is acceptable for now. This note captures a +clean future cut once overlapping frontier work becomes real enough to justify +the model change. + +## Thesis + +Frontiers should be scopes, not partitions. + +`hypothesis` and `experiment` are the real scientific graph vertices. +`frontier` is a control object with a brief and a bounded grounding projection. +It should not own vertices exclusively. + +The current model still treats frontier as an ownership partition: + +- every hypothesis has one `frontier_id` +- every experiment has one `frontier_id` +- experiments inherit that frontier from their owning hypothesis +- influence edges may not cross frontier boundaries + +That is stricter than the real ontology. In practice: + +- one hypothesis may matter to multiple frontiers +- one experiment may be relevant to multiple frontiers +- later hypotheses may be directly informed by experiments from another frontier + +The current shape is tolerable while there is effectively one live frontier. It +is not the right long-term model. + +## Desired Ontology + +### Graph vertices + +The true graph vertices remain: + +- `hypothesis` +- `experiment` + +### Control objects + +`frontier` is not a graph vertex. It is a scope object that owns: + +- label +- objective +- status +- brief + +### Sidecars + +`artifact` remains an attachable reference-only sidecar. + +## Relations + +Two relations remain fundamental. + +### 1. Ownership + +Every experiment has exactly one owning hypothesis. + +This is the canonical tree spine. It should remain mandatory and singular. + +### 2. Influence + +Hypotheses and experiments may both influence later hypotheses or experiments. + +This is the sparse DAG laid over the ownership spine. It should no longer be +artificially constrained by frontier boundaries. + +### 3. Membership + +Frontier membership becomes a separate many-to-many relation. + +A hypothesis or experiment may belong to zero, one, or many frontiers. + +This relation is about scope and visibility, not causality. + +## Recommended Data Model + +### Remove vertex-owned frontier identity + +Delete the idea that hypotheses or experiments intrinsically belong to exactly +one frontier. + +Concretely, the long-term cut should remove: + +- `HypothesisRecord.frontier_id` +- `ExperimentRecord.frontier_id` +- `CrossFrontierInfluence` + +### Add explicit membership tables + +Use explicit membership relations instead: + +- `frontier_hypotheses(frontier_id, hypothesis_id, added_at)` +- `frontier_experiments(frontier_id, experiment_id, added_at)` + +The split tables are preferable to one polymorphic membership table because the +types and invariants are simpler, and queries stay more direct. + +### Preserve one hard invariant + +An experiment may only be attached to a frontier if its owning hypothesis is +also attached to that frontier. + +This prevents the frontier scope from containing orphan experiments whose +canonical spine is missing from the same view. + +That still allows: + +- one hypothesis in multiple frontiers +- one experiment in multiple frontiers +- one experiment in a subset of its hypothesis frontiers + +but disallows: + +- experiment in frontier `B` while owning hypothesis is absent from `B` + +## Query Semantics + +### `frontier.open` + +`frontier.open` should derive its surface from membership, not ownership. + +Its bounded output should still be: + +- frontier brief +- active tags +- live metric keys +- active hypotheses with deduped current state +- open experiments + +But all of those should be computed from frontier members. + +### Active hypotheses + +Active hypotheses should be derived from a bounded combination of: + +- roadmap membership in the frontier brief +- hypotheses with open experiments in the frontier +- hypotheses with latest non-rejected closed experiments still relevant to the + frontier + +The exact rule can stay implementation-local as long as the result is bounded +and legible. + +### Live metrics + +The right default is not “all metrics touched by frontier members.” + +The live metric set should be derived from: + +- all open experiments in the frontier +- the immediate comparison context for those open experiments + +A good default comparison context is: + +- the union of metric keys on all open experiments +- plus the metric keys on immediate experiment ancestors of those open + experiments + +This keeps the hot path focused on the active A/B comparison set rather than +every historical metric ever observed in the scope. + +## Surface Changes + +When this cut happens, the public model should grow explicit membership +operations rather than smuggling scope through create-time ownership. + +Likely surfaces: + +- `frontier.member.add` +- `frontier.member.remove` +- `frontier.member.list` + +Or, if we prefer type-specific verbs: + +- `frontier.hypothesis.add` +- `frontier.hypothesis.remove` +- `frontier.experiment.add` +- `frontier.experiment.remove` + +I prefer the type-specific form because it is clearer for agents and avoids a +generic weakly-typed membership tool. + +Read/list filters should then interpret `frontier=` as membership selection. + +## Brief and Roadmap Semantics + +The frontier brief remains a singleton owned by the frontier. + +Its roadmap should reference frontier-member hypotheses only. + +That is a healthy constraint: + +- the brief is a scoped grounding object +- roadmap entries should not point outside the scope they summarize + +If a hypothesis becomes relevant to a frontier roadmap, attach it to that +frontier first. + +## Migration Shape + +This should be a red cut when it happens. + +No backward-compatibility layer is needed if the project is still early enough +that re-seeding is acceptable. + +The migration is straightforward: + +1. Create frontier membership tables. +2. For every hypothesis, insert one membership row from its current + `frontier_id`. +3. For every experiment, insert one membership row from its current + `frontier_id`. +4. Drop `frontier_id` from hypotheses and experiments. +5. Delete `CrossFrontierInfluence`. +6. Rewrite frontier-scoped queries to use membership joins. + +Because the current world is effectively single-frontier, this is mostly a +normalization cut rather than a semantic salvage operation. + +## Why This Is Better + +This model matches the clarified ontology: + +- the scientific truth lives in hypotheses and experiments +- the frontier is a bounded lens over that truth +- scope should not distort causality + +It also makes later comparative work cleaner: + +- one hypothesis can be reused across multiple frontier narratives +- one experiment can inform more than one frontier without duplication +- influence edges can remain honest even when they cross scope boundaries + +That is a better fit for a system whose real purpose is an austere experimental +record spine rather than a partitioned project tracker. diff --git a/scripts/install-local.sh b/scripts/install-local.sh index b087f70..b60027e 100755 --- a/scripts/install-local.sh +++ b/scripts/install-local.sh @@ -3,9 +3,18 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" SKILL_SOURCE_ROOT="${ROOT_DIR}/assets/codex-skills" +SYSTEMD_TEMPLATE_ROOT="${ROOT_DIR}/assets/systemd" LOCAL_ROOT="${1:-$HOME/.local}" SKILL_DEST_ROOT="${2:-$HOME/.codex/skills}" LOCAL_BIN_DIR="${LOCAL_ROOT}/bin" +SYSTEMD_USER_DIR="${HOME}/.config/systemd/user" +UI_SERVICE_NAME="${FIDGET_SPINNER_UI_SERVICE_NAME:-fidget-spinner-libgrid-ui.service}" +UI_PROJECT_ROOT="${FIDGET_SPINNER_UI_PROJECT:-$HOME/programming/projects/libgrid/.worktrees/libgrid-lp-oracle-cutset}" +UI_BIND="${FIDGET_SPINNER_UI_BIND:-127.0.0.1:8913}" + +escape_sed_replacement() { + printf '%s' "$1" | sed -e 's/[\\/&]/\\&/g' +} install_skill_link() { local name="$1" @@ -17,6 +26,78 @@ install_skill_link() { printf 'installed skill symlink: %s -> %s\n' "${dest_dir}" "${source_dir}" } +listener_pid_for_bind() { + local bind="$1" + local port="${bind##*:}" + ss -ltnp "( sport = :${port} )" 2>/dev/null \ + | sed -n 's/.*pid=\([0-9]\+\).*/\1/p' \ + | head -n 1 +} + +evict_conflicting_navigator() { + local pid + pid="$(listener_pid_for_bind "${UI_BIND}")" + if [[ -z "${pid}" ]]; then + return 0 + fi + local cmd + cmd="$(ps -p "${pid}" -o args= || true)" + if [[ "${cmd}" == *"fidget-spinner-cli ui serve"* ]]; then + kill "${pid}" + for _ in {1..20}; do + if ! kill -0 "${pid}" 2>/dev/null; then + printf 'stopped conflicting navigator process: pid=%s\n' "${pid}" + return 0 + fi + sleep 0.1 + done + printf 'failed to stop conflicting navigator process: pid=%s\n' "${pid}" >&2 + return 1 + fi + printf 'refusing to steal %s from non-spinner process: %s\n' "${UI_BIND}" "${cmd}" >&2 + return 1 +} + +install_libgrid_ui_service() { + if [[ ! -d "${UI_PROJECT_ROOT}" ]]; then + printf 'libgrid navigator root does not exist: %s\n' "${UI_PROJECT_ROOT}" >&2 + return 1 + fi + if ! command -v systemctl >/dev/null 2>&1; then + printf 'systemctl unavailable; skipping navigator service install\n' >&2 + return 0 + fi + + local service_path="${SYSTEMD_USER_DIR}/${UI_SERVICE_NAME}" + local template_path="${SYSTEMD_TEMPLATE_ROOT}/${UI_SERVICE_NAME}.in" + mkdir -p "${SYSTEMD_USER_DIR}" + sed \ + -e "s|@HOME@|$(escape_sed_replacement "${HOME}")|g" \ + -e "s|@LOCAL_BIN_DIR@|$(escape_sed_replacement "${LOCAL_BIN_DIR}")|g" \ + -e "s|@UI_PROJECT_ROOT@|$(escape_sed_replacement "${UI_PROJECT_ROOT}")|g" \ + -e "s|@UI_BIND@|$(escape_sed_replacement "${UI_BIND}")|g" \ + "${template_path}" > "${service_path}" + chmod 0644 "${service_path}" + printf 'installed user service: %s\n' "${service_path}" + + export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" + if [[ -z "${DBUS_SESSION_BUS_ADDRESS:-}" && -S "${XDG_RUNTIME_DIR}/bus" ]]; then + export DBUS_SESSION_BUS_ADDRESS="unix:path=${XDG_RUNTIME_DIR}/bus" + fi + if ! systemctl --user daemon-reload; then + printf 'systemd user manager unavailable; skipping navigator service activation\n' >&2 + return 0 + fi + evict_conflicting_navigator + if systemctl --user is-enabled --quiet "${UI_SERVICE_NAME}"; then + systemctl --user restart "${UI_SERVICE_NAME}" + printf 'restarted user service: %s\n' "${UI_SERVICE_NAME}" + else + systemctl --user enable --now "${UI_SERVICE_NAME}" + printf 'enabled user service: %s\n' "${UI_SERVICE_NAME}" + fi +} + mkdir -p "${LOCAL_BIN_DIR}" cargo build --release -p fidget-spinner-cli --manifest-path "${ROOT_DIR}/Cargo.toml" @@ -28,5 +109,6 @@ printf 'installed binary: %s\n' "${LOCAL_BIN_DIR}/fidget-spinner-cli" install_skill_link "fidget-spinner" install_skill_link "frontier-loop" +install_libgrid_ui_service printf 'mcp command: %s\n' "${LOCAL_BIN_DIR}/fidget-spinner-cli mcp serve" -- cgit v1.2.3