diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 3b18485..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,23 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -env: - CARGO_TERM_COLOR: always - -jobs: - check: - name: Check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions-rust-lang/setup-rust-toolchain@v1 - with: - components: clippy, rustfmt - - run: cargo fmt --check - - run: cargo clippy --all-targets -- -D warnings - - run: cargo test diff --git a/.gitignore b/.gitignore index ea8c4bf..eb1eae4 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ /target + +# Local development overrides (see .cargo/config.toml inside) +/.cargo + +# Scaffolded plugins (dev-time tooling) +/plugins-dev diff --git a/CHANGELOG.md b/CHANGELOG.md index 70ac144..55c7079 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,73 @@ # Changelog +## [0.3.0] - 2026-09-04 + +### Ecosystem externalization (monocrate cycle complete) +- The kernel is now a thin host over the externalized ecosystem. It + consumes the contract crates from `xtop-cli/api` (`xtop-plugin-api`, + `xtop-widget-api`, `xtop-extension-api`), the widget packs from + `xtop-cli/widgets`, layouts from `xtop-cli/layouts`, the samurai plugin + from `xtop-cli/plugins` and the MCP extension from `xtop-cli/extensions` + as floating git dependencies with optional feature flags. +- Deleted the dead uncompiled `src/commands/plugins_dir_tmp/` leftover + from the pre-monocrate layout. (Path spelled in scripts/audit.sh as the + absence guard.) +- `crate version 0.2.0 -> 0.3.0`; `rust-version = "1.87"` declared. + +### Local-only CI +- The GitHub Actions CI workflow (`.github/workflows/ci.yml`) has been + removed: gating is local only (`scripts/ci.sh`, `scripts/audit.sh`) and + will not be re-enabled until the release pipeline is 100% ready. + +### Contract consolidation +- `config::AlertThresholds` removed: the persisted config now uses + `xtop_plugin_api::AlertThresholds` directly (identical JSON keys + `cpu_high`/`mem_high`/`disk_high`; defaults 90/90/90 constructed at the + config layer). Hand-marshalling bridge code deleted from + `plugins/host.rs` and `state/widget_state.rs`. +- Plugin widget registrations use `xtop_plugin_api::PluginWidget`; the + duplicated `PluginWidgetFn` alias in `ui/layout/engine.rs` is gone. +- Docker leftovers removed (`dockers: vec![]` assignment, `docker_info` + composite forwarding) — the api model no longer carries Docker data. +- Plugin context reads are capability-gated and `Result`-typed upstream; + consumers adapted. + +### Themes +- `miami` is now embedded and seeded like the other 11 themes (12 JSONC + theme files ship, all in `DEFAULT_THEMES`); seed version bumped to "2" + so existing installs receive the new template. +- Theme docs/counts corrected everywhere (12 themes; `x` compiled in as + the startup fallback). +- `theme::themes_dir()` routes through the platform-aware + `config::config_dir()`, so themes live next to `config.json` and layouts + on macOS/Windows too. + +### Dependency refresh +- ratatui `0.29 -> 0.30.2` (`Layout::vertical`/`Layout::horizontal` + builder API); crossterm aligned with ratatui's backend (single + `crossterm 0.29` in the graph via `ratatui-crossterm`). + +### New features +- Unknown widget names in the active layout produce a one-time stderr + warning per name (`xtop: layout '' references unknown widget + ''`). +- Optional `effects` feature (off by default) wires the built-in + `xtop-effect-fade` through the `effect` config key ("fade" activates the + 500 ms fade-in; absent/unknown disables). Builds without the feature + carry zero extra dependencies. + +### Docs and release plumbing +- `docs/` refreshed: configuration keys, plugin architecture (plugins as + separate crates, `plugin list|install|scaffold` real behavior, MCP + extension), multi-repo RFC status, feature/theme counts; `colors.md` + moved under `docs/`. +- `install.sh` VERSION synced to the crate version; OpenSSL build-dep + claims removed (the crate has no openssl dependency); `install.ps1` + placeholder comment removed. +- Kernel `ROADMAP.md` synced with the implemented state (phases 4-6 and + the R2/R3 refactor items); `scripts/audit.sh` extended with dead-dir and + theme-seed gates. + ## [0.2.1] - 2026-06-18 ### Config: Persistencia de Layouts Personalizados diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a9609f1..54aeb23 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,7 +5,7 @@ First off, thank you for considering contributing to `xtop`! It's people like yo ## How Can I Contribute? ### Reporting Bugs -- Ensure the bug was not already reported by searching on GitHub under [Issues](https://github.com/xscriptor/xtop/issues). +- Ensure the bug was not already reported by searching on GitHub under [Issues](https://github.com/xtop-cli/xtop/issues). - If you're unable to find an open issue addressing the problem, open a new one. Be sure to include a title and clear description, as much relevant information as possible, and a code sample or an executable test case demonstrating the expected behavior that is not occurring. ### Suggesting Enhancements @@ -23,7 +23,7 @@ First off, thank you for considering contributing to `xtop`! It's people like yo ```bash # Clone the repository -git clone https://github.com/xscriptor/xtop.git +git clone https://github.com/xtop-cli/xtop.git cd xtop # Build the project diff --git a/Cargo.lock b/Cargo.lock index e6b6971..a1bcef3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4,9 +4,9 @@ version = 4 [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -19,15 +19,15 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "bitflags" -version = "2.12.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" [[package]] name = "cassowary" @@ -91,9 +91,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ "darling_core", "darling_macro", @@ -101,26 +101,26 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn", + "syn 3.0.4", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -135,9 +135,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "equivalent" @@ -192,9 +192,9 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -211,15 +211,15 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +checksum = "2bf84e73fa6f27f299dec58e13223cf70db80da872eb921d4f6138342a0eabc8" dependencies = [ "darling", "indoc", "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] @@ -239,9 +239,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "linux-raw-sys" @@ -260,9 +260,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.31" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b30b4cd05f7c06868fdb2854f66a7b9fece9a48425351cd532e810d74024f" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru" @@ -275,15 +275,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mio" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", @@ -388,18 +388,18 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -436,9 +436,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -448,9 +448,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -478,9 +478,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -496,9 +496,9 @@ checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -506,29 +506,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.4", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -579,9 +579,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" [[package]] name = "static_assertions" @@ -614,14 +614,25 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.119", ] [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" dependencies = [ "proc-macro2", "quote", @@ -630,9 +641,9 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.39.3" +version = "0.39.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21d0d938c10fcda3e897e28aaddf4ab462375d411f4378cd63b1c945f69aba96" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" dependencies = [ "libc", "memchr", @@ -800,7 +811,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -811,7 +822,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -954,46 +965,95 @@ version = "0.2.0" dependencies = [ "anyhow", "crossterm", + "ratatui", + "serde", "serde_json", + "sysinfo", "toml", - "xtop-core", - "xtop-plugin-sentinel", - "xtop-tui", + "xtop-extension-api", + "xtop-extension-mcp", + "xtop-layout", + "xtop-plugin-api", + "xtop-plugin-samurai", + "xtop-widget-api", + "xtop-widget-blocks", + "xtop-widgets", ] [[package]] -name = "xtop-core" -version = "0.2.0" +name = "xtop-extension-api" +version = "0.1.0" +source = "git+https://github.com/xtop-cli/api#2a3f33ae7151a5b0a15978d37cb6c4afb3359205" + +[[package]] +name = "xtop-extension-mcp" +version = "0.1.0" +source = "git+https://github.com/xtop-cli/extensions#5d9d4b08bca6cf77ac0bc905c2c79148f1d0255c" +dependencies = [ + "serde_json", + "xtop-extension-api", +] + +[[package]] +name = "xtop-layout" +version = "0.1.0" +source = "git+https://github.com/xtop-cli/layouts#99e9c8a2be60e52aa52b41e700206bf25d6d5aca" dependencies = [ - "ratatui", "serde", "serde_json", - "sysinfo", ] [[package]] -name = "xtop-plugin-sentinel" +name = "xtop-plugin-api" +version = "0.1.0" +source = "git+https://github.com/xtop-cli/api#2a3f33ae7151a5b0a15978d37cb6c4afb3359205" +dependencies = [ + "ratatui", +] + +[[package]] +name = "xtop-plugin-samurai" version = "0.2.0" +source = "git+https://github.com/xtop-cli/plugins#7d49f7ec246ec99e964a045ddaa356a627396b8b" dependencies = [ - "anyhow", "ratatui", "regex", "serde", "serde_json", - "xtop-core", + "xtop-plugin-api", ] [[package]] -name = "xtop-tui" -version = "0.2.0" +name = "xtop-widget-api" +version = "0.1.0" +source = "git+https://github.com/xtop-cli/api#2a3f33ae7151a5b0a15978d37cb6c4afb3359205" dependencies = [ - "crossterm", "ratatui", - "xtop-core", + "serde", + "xtop-plugin-api", +] + +[[package]] +name = "xtop-widget-blocks" +version = "0.1.0" +source = "git+https://github.com/xtop-cli/widgets#cdd72c6aac028ed6e71a7d4d37be0f8c8d1f498a" +dependencies = [ + "ratatui", + "xtop-widget-api", +] + +[[package]] +name = "xtop-widgets" +version = "0.1.0" +source = "git+https://github.com/xtop-cli/widgets#cdd72c6aac028ed6e71a7d4d37be0f8c8d1f498a" +dependencies = [ + "ratatui", + "xtop-plugin-api", + "xtop-widget-api", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 11409cc..cbb6a76 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,21 +1,44 @@ -[workspace] -resolver = "2" -members = [ - "crates/xtop-core", - "crates/xtop-tui", - "crates/xtop-cli", - "plugins/xtop-plugin-sentinel", -] - -[workspace.package] -version = "0.2.0" +[package] +name = "xtop" +version = "0.3.0" edition = "2021" +rust-version = "1.87" license = "MIT" +description = "A modern, cross-platform TUI system monitor written in Rust" +repository = "https://github.com/xtop-cli/xtop" + +[[bin]] +name = "xtop" +path = "src/main.rs" -[workspace.dependencies] -ratatui = "0.29" -crossterm = "0.28" +[dependencies] +crossterm = "0.29" +ratatui = "0.30.2" sysinfo = "0.39" anyhow = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" +toml = "0.8" + +# Contract crates from xtop-cli/api and the ecosystem repos (xtop-cli/plugins, +# xtop-cli/extensions, xtop-cli/layouts, xtop-cli/widgets, xtop-cli/effects). +# Distribution: git dependencies on the published repos, so a clean clone +# builds without needing their sources checked out locally. +xtop-plugin-api = { git = "https://github.com/xtop-cli/api" } +xtop-extension-api = { git = "https://github.com/xtop-cli/api" } +xtop-widget-api = { git = "https://github.com/xtop-cli/api" } +xtop-effect-api = { git = "https://github.com/xtop-cli/api", optional = true } +# xtop-cli/widgets (base and alternative packs, in their own repo). +xtop-widgets = { git = "https://github.com/xtop-cli/widgets" } +xtop-widget-blocks = { git = "https://github.com/xtop-cli/widgets", optional = true } +xtop-layout = { git = "https://github.com/xtop-cli/layouts" } +xtop-extension-mcp = { git = "https://github.com/xtop-cli/extensions", optional = true } +xtop-plugin-samurai = { git = "https://github.com/xtop-cli/plugins", optional = true } +xtop-effect-fade = { git = "https://github.com/xtop-cli/effects", optional = true } + +[features] +default = ["plugin-samurai", "mcp-extension"] +plugin-samurai = ["dep:xtop-plugin-samurai"] +mcp-extension = ["dep:xtop-extension-mcp"] +widget-blocks = ["dep:xtop-widget-blocks"] +effects = ["dep:xtop-effect-fade", "dep:xtop-effect-api"] diff --git a/PKGBUILD b/PKGBUILD index 62126ae..853ad06 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -5,7 +5,7 @@ pkgver=r7.37fae84 pkgrel=1 pkgdesc="A btop-like system monitor written in Rust for X" arch=('x86_64') -url="https://github.com/xscriptor/xtop" +url="https://github.com/xtop-cli/xtop" license=('MIT') depends=('gcc-libs') makedepends=('git' 'cargo') diff --git a/README.md b/README.md index fe2f2b3..c6a61c9 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,12 @@
-![Rust](https://img.shields.io/badge/Rust-1.80%2B-orange) +![Rust](https://img.shields.io/badge/Rust-1.87%2B-orange) ![License](https://img.shields.io/badge/license-MIT-blue) -![CI](https://img.shields.io/github/actions/workflow/status/xscriptor/xtop/ci.yml?branch=main) ![Platform](https://img.shields.io/badge/platform-linux%20%7C%20macos%20%7C%20windows-lightgrey) ![ratatui](https://img.shields.io/badge/built%20with-ratatui-red) -A cross-platform TUI system monitor written in Rust. Uses ratatui for the terminal interface and sysinfo for real-time system metrics. +A cross-platform TUI system monitor written in Rust. Uses ratatui for the terminal interface and sysinfo for real-time system metrics.
@@ -38,9 +37,9 @@ A cross-platform TUI system monitor written in Rust. Uses Quick Install -

macOS / Linux

+

Linux

-
curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash
+
curl -fsSL https://raw.githubusercontent.com/xtop-cli/xtop/main/install.sh | bash

Windows (PowerShell)

-
irm https://raw.githubusercontent.com/xscriptor/xtop/main/install.ps1 | iex
+
irm https://raw.githubusercontent.com/xtop-cli/xtop/main/install.ps1 | iex
+ +

macOS and other platforms

+ +

Build from source (see below). The install.sh script detects Linux +package managers; macOS users need a working Rust toolchain and run the +build-from-source steps.

Build from Source

-
git clone https://github.com/xscriptor/xtop.git
+
git clone https://github.com/xtop-cli/xtop.git
 cd xtop
 cargo run --release
@@ -115,6 +120,9 @@ cargo run --release
  • Usage -- keybindings, modules, help overlay
  • Configuration -- config file and settings reference
  • Customization -- custom themes and layouts
  • +
  • Colors -- palette reference for the 12 shipped themes
  • +
  • Plugins -- plugin architecture and authoring
  • +
  • Multi-repo architecture -- ecosystem RFC and layout
  • Roadmap
  • Changelog
  • Contributing
  • @@ -126,14 +134,8 @@ cargo run --release

    X

    - - X Web - +Dev & - - X Github Profile - +Github Profile & - - Xscriptor web - \ No newline at end of file +Xscriptor \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md index 744d555..0a5f037 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -18,6 +18,8 @@ It is synced automatically with GitHub Issues. - [x] Dynamic layout manager supporting multiple modes (Dashboard, Vertical, Process Focus) (#9) - [x] Implement 13 built-in color schemes (`x`, `madrid`, `tokio`, etc.) (#10) + (12 theme files ship today, all embedded and seeded; the 0.3.0 docs + count 12. See docs/colors.md for the palette reference.) - [x] Instant theme and layout cycling at runtime (#11) - [x] Responsive design for narrow terminals (#12) @@ -31,25 +33,35 @@ It is synced automatically with GitHub Issues. ## Phase 4: Configuration & Customization -- [ ] Persistent configuration file support (save theme and layout preferences) (#18) -- [ ] Custom user theme creation via configuration (#19) -- [ ] Configurable update intervals for system metrics (#20) -- [ ] Customizable keybindings (#21) +- [x] Persistent configuration file support (save theme and layout preferences) (#18) +- [x] Custom user theme creation via configuration (#19) +- [x] Configurable update intervals for system metrics (#20) +- [x] Customizable keybindings (#21) ## Phase 5: Advanced Monitoring Features -- [ ] Disk I/O read/write speed tracking (#22) -- [ ] Granular network interface selection (#23) -- [ ] GPU usage, temperature, and VRAM monitoring (NVIDIA/AMD) (#24) -- [ ] Battery status monitoring (#25) -- [ ] Docker container resource usage integration (#26) +- [x] Disk I/O read/write speed tracking (#22) +- [ ] Granular network interface selection (#23) — per-interface RX/TX data is + shown, but picking which interface the kernel reports/charts is not + configurable yet. +- [ ] GPU usage, temperature, and VRAM monitoring (NVIDIA/AMD) (#24) — + Linux-only partial (nvidia-smi + /sys/class/drm); macOS/Windows stay + stubs. +- [ ] Battery status monitoring (#25) — Linux probes real; macOS/Windows + stay stubs. +- [ ] Docker container resource usage integration (#26) — Docker support was + removed from the shared data model (api M1.4: nothing consumed it), so + this item is parked until a real consumer appears. ## Phase 6: Interactive Process Management -- [ ] Interactive process termination (send kill signals) (#27) -- [ ] Search, filter, and highlight processes by name (#28) +- [x] Interactive process termination (send kill signals) (#27) +- [ ] Search, filter, and highlight processes by name (#28) — search and + filtering exist; highlighting matches inside the process list is + pending. - [ ] Tree view for process hierarchy (#29) -- [ ] Sorting processes by Memory, PID, or User (#30) +- [ ] Sorting processes by Memory, PID, or User (#30) — CPU/Memory/PID/Name + sorting is implemented; no User column yet. ## Phase 7: X Integration @@ -57,4 +69,46 @@ It is synced automatically with GitHub Issues. - [ ] Create xp package (#32) - [ ] Add to X Repositories (#33) - +## Phase 8: Kernel Refactor + +Baseline done: single-crate kernel (src/ by areas: config, theme, layout, +state, plugins, providers, ui, commands); plugin and extension hosts over +the api contracts; samurai and the mcp extension live in their own repos. +Only pending refactor work is listed here (git logistics are handled +outside this roadmap). + +### R2 - Code quality pass + +- [x] Module doc comments audit across src/ (#40) — the seven top-level + modules declared in src/main.rs (commands, config, plugins, providers, + state, theme, ui) all carry concise `//!` docs describing their area. +- [x] cfg(target_os) only inside platform/ trees (#41) — enforced by + scripts/audit.sh (0 occurrences outside platform/). +- [ ] Wildcard re-export and pub hygiene review (#42) — 22 wildcard + `pub use ...::*` re-exports remain (audit.sh threshold: 30); review + still open. +- [x] Split commands/plugins.rs into list/install/scaffold modules (#43) +- [x] key_event_to_str into a shared input module if reused elsewhere (#44) — + single use site (commands/run.rs), so no shared module is needed. +- [x] ui/share/error.rs only when real UI error handling appears (no empty + modules) (#45) — intentionally not created; documented in the root + ROADMAP deferred list. +- [x] Widgets subdivide internally when they outgrow one module (#46) — + kernel widget renderers were externalized to the widgets repo (M3); + the kernel no longer owns pack widgets. + +### R3 - Structural audit tooling + +- [x] scripts/audit.sh with failing thresholds (#47) — the script gates: + cfg(target_os) outside platform/ trees = 0, files over 600 lines = 0, + TODO/FIXME/XXX/HACK markers = 0, wildcard `pub use ...::*` <= 30, + LOC per top-level area <= 2400, dead pre-monocrate plugin tree absent + (its path lives only inside the audit script as the guard itself), + `miami` embedded in the theme seeds (12 themes total). Module + dependency graph/cycles and unused-pub detection are NOT implemented + by the script (see the deferred note below). + +Deferred follow-ups (tracked with the root ROADMAP §7 list): +- audit.sh module dependency graph/cycles and unused-pub detection, plus + per-file thresholds above 300/600 lines (the 200-line figure in the + original issue predates the current 300/600 gates). diff --git a/assets/layouts/cpu_focus.jsonc b/assets/layouts/cpu_focus.jsonc deleted file mode 100644 index 13e24b0..0000000 --- a/assets/layouts/cpu_focus.jsonc +++ /dev/null @@ -1,12 +0,0 @@ -{ - // CPU Focus: CPU takes 60%, processes the rest - "name": "CPU Focus", - "root": { - "direction": "vertical", - "areas": [ - { "widget": "header", "size": 3 }, - { "widget": "cpu", "size": "60%" }, - { "widget": "processes", "size": "*" } - ] - } -} diff --git a/assets/layouts/dashboard.jsonc b/assets/layouts/dashboard.jsonc deleted file mode 100644 index 9444014..0000000 --- a/assets/layouts/dashboard.jsonc +++ /dev/null @@ -1,27 +0,0 @@ -{ - // Dashboard: default 2-column layout - "name": "Dashboard", - "root": { - "direction": "vertical", - "areas": [ - { "widget": "header", "size": 3 }, - { - "direction": "horizontal", - "size": "45%", - "areas": [ - { "widget": "cpu", "size": "50%" }, - { - "direction": "vertical", - "size": "50%", - "areas": [ - { "widget": "memory", "size": "33%" }, - { "widget": "storage", "size": "33%" }, - { "widget": "network", "size": "34%" } - ] - } - ] - }, - { "widget": "processes", "size": "52%" } - ] - } -} diff --git a/assets/layouts/horizontal.jsonc b/assets/layouts/horizontal.jsonc deleted file mode 100644 index 03c39ae..0000000 --- a/assets/layouts/horizontal.jsonc +++ /dev/null @@ -1,20 +0,0 @@ -{ - // Horizontal: 4 widgets side-by-side - "name": "Horizontal", - "root": { - "direction": "vertical", - "areas": [ - { "widget": "header", "size": 3 }, - { - "direction": "horizontal", - "size": "*", - "areas": [ - { "widget": "cpu", "size": "25%" }, - { "widget": "memory", "size": "25%" }, - { "widget": "storage", "size": "25%" }, - { "widget": "network", "size": "25%" } - ] - } - ] - } -} diff --git a/assets/layouts/memory_focus.jsonc b/assets/layouts/memory_focus.jsonc deleted file mode 100644 index 5630296..0000000 --- a/assets/layouts/memory_focus.jsonc +++ /dev/null @@ -1,12 +0,0 @@ -{ - // Memory Focus: Memory takes 60%, processes the rest - "name": "Memory Focus", - "root": { - "direction": "vertical", - "areas": [ - { "widget": "header", "size": 3 }, - { "widget": "memory", "size": "60%" }, - { "widget": "processes", "size": "*" } - ] - } -} diff --git a/assets/layouts/network_focus.jsonc b/assets/layouts/network_focus.jsonc deleted file mode 100644 index d5c2b30..0000000 --- a/assets/layouts/network_focus.jsonc +++ /dev/null @@ -1,19 +0,0 @@ -{ - // Network Focus: Network + Disk I/O side by side, process list below - "name": "Network Focus", - "root": { - "direction": "vertical", - "areas": [ - { "widget": "header", "size": 3 }, - { - "direction": "horizontal", - "size": "50%", - "areas": [ - { "widget": "network", "size": "50%" }, - { "widget": "disk_io", "size": "50%" } - ] - }, - { "widget": "processes", "size": "*" } - ] - } -} diff --git a/assets/layouts/process_focus.jsonc b/assets/layouts/process_focus.jsonc deleted file mode 100644 index a3417ee..0000000 --- a/assets/layouts/process_focus.jsonc +++ /dev/null @@ -1,21 +0,0 @@ -{ - // Process Focus: mini stats row at top, full process list below - "name": "Process Focus", - "root": { - "direction": "vertical", - "areas": [ - { "widget": "header", "size": 3 }, - { - "direction": "horizontal", - "size": 8, - "areas": [ - { "widget": "cpu", "size": "25%" }, - { "widget": "memory", "size": "25%" }, - { "widget": "storage", "size": "25%" }, - { "widget": "network", "size": "25%" } - ] - }, - { "widget": "processes", "size": "*" } - ] - } -} diff --git a/assets/layouts/vertical.jsonc b/assets/layouts/vertical.jsonc deleted file mode 100644 index 864f022..0000000 --- a/assets/layouts/vertical.jsonc +++ /dev/null @@ -1,15 +0,0 @@ -{ - // Vertical: all widgets stacked top-to-bottom - "name": "Vertical", - "root": { - "direction": "vertical", - "areas": [ - { "widget": "header", "size": 3 }, - { "widget": "cpu", "size": 8 }, - { "widget": "memory", "size": 8 }, - { "widget": "storage", "size": 6 }, - { "widget": "network", "size": 5 }, - { "widget": "processes", "size": "*" } - ] - } -} diff --git a/crates/xtop-cli/Cargo.toml b/crates/xtop-cli/Cargo.toml deleted file mode 100644 index f54c1ed..0000000 --- a/crates/xtop-cli/Cargo.toml +++ /dev/null @@ -1,20 +0,0 @@ -[package] -name = "xtop" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -xtop-core = { path = "../xtop-core" } -xtop-tui = { path = "../xtop-tui" } -crossterm.workspace = true -anyhow.workspace = true -serde_json.workspace = true -toml = "0.8" - -# Optional plugins (behind feature flags) -xtop-plugin-sentinel = { path = "../../plugins/xtop-plugin-sentinel", optional = true } - -[features] -default = ["plugin-sentinel"] -plugin-sentinel = ["dep:xtop-plugin-sentinel"] diff --git a/crates/xtop-cli/src/main.rs b/crates/xtop-cli/src/main.rs deleted file mode 100644 index c66a4e5..0000000 --- a/crates/xtop-cli/src/main.rs +++ /dev/null @@ -1,742 +0,0 @@ -use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers}; -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{Duration, Instant}; -use xtop_core::application::plugin_manager::PluginManager; -use xtop_core::application::state::{AppState, Config, InputMode, PalettePage}; -use xtop_core::domain::keybinding::Action; -use xtop_core::infrastructure::composite_provider::CompositeProvider; -use xtop_core::infrastructure::config; -use xtop_core::infrastructure::layout_loader; -use xtop_core::infrastructure::sysinfo_provider::SysinfoProvider; -use xtop_core::infrastructure::theme_loader::load_all_themes; -use xtop_tui::render; -use xtop_tui::terminal; - -mod mcp; - -// --------------------------------------------------------------------------- -// Plugin imports (feature-gated) -// --------------------------------------------------------------------------- -#[cfg(feature = "plugin-sentinel")] -use xtop_plugin_sentinel::SentinelPlugin; - -// --------------------------------------------------------------------------- -// Config dir helper (delegated to xtop-core to avoid duplication) -// --------------------------------------------------------------------------- -fn config_dir() -> PathBuf { - xtop_core::infrastructure::config::config_dir() -} - -fn key_event_to_str(key: &KeyEvent) -> String { - let mut s = String::new(); - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - if ctrl { - s.push_str("ctrl+"); - } - if key.modifiers.contains(KeyModifiers::ALT) { - s.push_str("alt+"); - } - match key.code { - KeyCode::Char(c) => { - if ctrl { - s.push(c.to_ascii_lowercase()); - } else { - s.push(c); - } - } - KeyCode::Esc => s.push_str("escape"), - KeyCode::Enter => s.push_str("enter"), - KeyCode::Backspace => s.push_str("backspace"), - KeyCode::Tab => s.push_str("tab"), - KeyCode::Up => s.push_str("up"), - KeyCode::Down => s.push_str("down"), - KeyCode::Left => s.push_str("left"), - KeyCode::Right => s.push_str("right"), - KeyCode::Delete => s.push_str("delete"), - KeyCode::Home => s.push_str("home"), - KeyCode::End => s.push_str("end"), - KeyCode::PageUp => s.push_str("pageup"), - KeyCode::PageDown => s.push_str("pagedown"), - _ => return String::new(), - } - s -} - -// Embedded default asset files (shipped with the binary) -const DEFAULT_THEMES: &[(&str, &str)] = &[ - ("x", include_str!("../../../assets/themes/x.jsonc")), - ( - "madrid", - include_str!("../../../assets/themes/madrid.jsonc"), - ), - ( - "lahabana", - include_str!("../../../assets/themes/lahabana.jsonc"), - ), - ("paris", include_str!("../../../assets/themes/paris.jsonc")), - ("tokio", include_str!("../../../assets/themes/tokio.jsonc")), - ("oslo", include_str!("../../../assets/themes/oslo.jsonc")), - ( - "helsinki", - include_str!("../../../assets/themes/helsinki.jsonc"), - ), - ( - "berlin", - include_str!("../../../assets/themes/berlin.jsonc"), - ), - ( - "london", - include_str!("../../../assets/themes/london.jsonc"), - ), - ("praha", include_str!("../../../assets/themes/praha.jsonc")), - ( - "bogota", - include_str!("../../../assets/themes/bogota.jsonc"), - ), -]; - -const DEFAULT_LAYOUTS: &[(&str, &str)] = &[ - ( - "dashboard", - include_str!("../../../assets/layouts/dashboard.jsonc"), - ), - ( - "vertical", - include_str!("../../../assets/layouts/vertical.jsonc"), - ), - ( - "horizontal", - include_str!("../../../assets/layouts/horizontal.jsonc"), - ), - ( - "cpu_focus", - include_str!("../../../assets/layouts/cpu_focus.jsonc"), - ), - ( - "memory_focus", - include_str!("../../../assets/layouts/memory_focus.jsonc"), - ), - ( - "network_focus", - include_str!("../../../assets/layouts/network_focus.jsonc"), - ), - ( - "process_focus", - include_str!("../../../assets/layouts/process_focus.jsonc"), - ), -]; - -fn ensure_default_assets() { - let theme_assets: &[(&str, &str)] = DEFAULT_THEMES; - let layout_assets: &[(&str, &str)] = DEFAULT_LAYOUTS; - - let dir = xtop_core::infrastructure::theme_loader::themes_dir(); - if !dir.join(".xtop_initialized").exists() { - fs::create_dir_all(&dir).ok(); - for (name, content) in theme_assets { - let path = dir.join(format!("{name}.jsonc")); - if !path.exists() { - fs::write(&path, content).ok(); - } - } - fs::write(dir.join(".xtop_initialized"), "").ok(); - } - - let dir = xtop_core::infrastructure::layout_loader::layouts_dir(); - if !dir.join(".xtop_initialized").exists() { - fs::create_dir_all(&dir).ok(); - for (name, content) in layout_assets { - let path = dir.join(format!("{name}.jsonc")); - if !path.exists() { - fs::write(&path, content).ok(); - } - } - fs::write(dir.join(".xtop_initialized"), "").ok(); - } -} - -fn save_config(state: &AppState) { - let layout_name = if state.layout_index < state.layout_defs.len() { - state.layout_defs[state.layout_index].name.clone() - } else { - String::new() - }; - let cfg = Config { - theme: state.current_theme.name.clone(), - layout_mode: state.save_layout_mode(), - layout_name, - update_interval_ms: state.update_interval_ms, - history_points: 100, - alerts: state.alerts, - keybindings: state.keybindings.clone(), - }; - let _ = config::save_config(&cfg); -} - -fn build_plugin_manager(state: &mut AppState, cfg_dir: &Path) -> PluginManager { - let plugins_dir = cfg_dir.join("plugins"); - fs::create_dir_all(&plugins_dir).ok(); - let mut mgr = PluginManager::new(plugins_dir); - - // Register plugins behind feature flags - #[cfg(feature = "plugin-sentinel")] - { - let plugin = Box::new(SentinelPlugin::new()); - if let Err(e) = mgr.register(plugin, state) { - eprintln!("[xtop] failed to load sentinel plugin: {e}"); - } - } - - mgr -} - -// --------------------------------------------------------------------------- -// CLI subcommands -// --------------------------------------------------------------------------- -fn print_usage() { - eprintln!("Usage:"); - eprintln!(" xtop Start the TUI system monitor"); - eprintln!(" xtop mcp Start MCP server (stdio transport) for AI agents"); - eprintln!(" xtop plugin list List installed plugins"); - eprintln!( - " xtop plugin install Install a plugin from github.com/xscriptor/xtop/plugins/" - ); - eprintln!(" xtop plugin install Install a plugin from a git URL"); - eprintln!(" xtop plugin scaffold Create a new plugin crate"); -} - -/// Check if a string looks like a git URL (not a simple name). -fn is_git_url(s: &str) -> bool { - s.contains("://") || s.contains("github.com") || s.contains("git@") -} - -fn cmd_plugin_list() { - let workspace_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .parent() - .unwrap() - .parent() - .unwrap() - .join("Cargo.toml"); - - let content = match fs::read_to_string(&workspace_path) { - Ok(c) => c, - Err(e) => { - eprintln!("Error reading workspace Cargo.toml: {e}"); - return; - } - }; - - // Parse workspace members for plugin crates - let mut in_members = false; - let mut plugins: Vec = Vec::new(); - for line in content.lines() { - let trimmed = line.trim(); - if trimmed.starts_with("members") { - in_members = true; - continue; - } - if in_members { - if trimmed == "]" { - break; - } - let name = trimmed.trim_matches(',').trim().trim_matches('"'); - if name.starts_with("plugins/xtop-plugin-") || name.starts_with("crates/xtop-plugin-") { - plugins.push(name.to_string()); - } - } - } - - if plugins.is_empty() { - println!("No plugins installed."); - return; - } - println!("Installed plugins:"); - for p in &plugins { - println!(" {p}"); - } -} - -fn cmd_plugin_install(name_or_url: &str) -> anyhow::Result<()> { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_dir = manifest_dir.parent().unwrap().parent().unwrap(); - let workspace_toml = workspace_dir.join("Cargo.toml"); - let cli_toml = manifest_dir.join("Cargo.toml"); - let plugins_dir = workspace_dir.join("plugins"); - - let tmp = std::env::temp_dir().join("xtop-plugin-install"); - let _ = fs::remove_dir_all(&tmp); - - let repo_url: &str; - let mut plugin_subdir: String = String::new(); - - if is_git_url(name_or_url) { - // URL-based: clone the repo directly - repo_url = name_or_url; - println!("Cloning {repo_url} ..."); - let status = std::process::Command::new("git") - .args(["clone", repo_url, tmp.to_str().unwrap()]) - .status() - .map_err(|e| anyhow::anyhow!("Failed to run git: {e}"))?; - if !status.success() { - anyhow::bail!("git clone failed"); - } - } else { - // Name-based: look in xtop repo's plugins/ directory - repo_url = "https://github.com/xscriptor/xtop.git"; - let candidate_names = [ - format!("plugins/xtop-plugin-{name_or_url}"), - format!("plugins/{name_or_url}"), - ]; - println!("Looking for plugin '{name_or_url}' in {repo_url} ..."); - let status = std::process::Command::new("git") - .args([ - "clone", - "--depth", - "1", - "--filter=blob:none", - "--sparse", - repo_url, - tmp.to_str().unwrap(), - ]) - .status() - .map_err(|e| anyhow::anyhow!("Failed to run git: {e}"))?; - if !status.success() { - anyhow::bail!("git clone failed"); - } - - // Try each candidate path - let mut found = false; - for candidate in &candidate_names { - if tmp.join(candidate).join("Cargo.toml").exists() { - plugin_subdir = candidate.clone(); - found = true; - break; - } - } - if !found { - let _ = fs::remove_dir_all(&tmp); - anyhow::bail!( - "Plugin '{name_or_url}' not found in plugins/. \ - Tried: {}", - candidate_names.join(", ") - ); - } - println!("Found plugin at {plugin_subdir}"); - } - - // --- Determine the plugin source directory --- - let plugin_src = if plugin_subdir.is_empty() { - // URL-based: cloned repo root - tmp.clone() - } else { - // Name-based: subdirectory within cloned xtop repo - tmp.join(&plugin_subdir) - }; - - // --- Read the plugin's Cargo.toml to get the package name --- - let plugin_toml_path = plugin_src.join("Cargo.toml"); - let plugin_toml_content = fs::read_to_string(&plugin_toml_path) - .map_err(|e| anyhow::anyhow!("No Cargo.toml found: {e}"))?; - let plugin_pkg: toml::Value = plugin_toml_content - .parse() - .map_err(|e| anyhow::anyhow!("Invalid Cargo.toml: {e}"))?; - - let pkg_name = plugin_pkg - .get("package") - .and_then(|p| p.get("name")) - .and_then(|n| n.as_str()) - .ok_or_else(|| anyhow::anyhow!("package.name not found in plugin Cargo.toml"))?; - - let feature_name = pkg_name.replace('-', "_"); - let plugin_dir_name = pkg_name.replace('-', "_"); - - println!("Package name: {pkg_name}"); - - // --- Copy into local plugins/ directory --- - let target_dir = plugins_dir.join(&plugin_dir_name); - if target_dir.exists() { - anyhow::bail!( - "Plugin '{}' already exists at plugins/{plugin_dir_name}", - pkg_name - ); - } - fs::create_dir_all(&plugins_dir)?; - cp_recursive(&plugin_src, &target_dir)?; - - // --- Add to workspace Cargo.toml --- - let ws_content = fs::read_to_string(&workspace_toml)?; - let member_entry = format!(" \"plugins/{plugin_dir_name}\""); - if ws_content.contains(&member_entry) { - anyhow::bail!("Already in workspace"); - } - // Insert before the closing bracket of members - let sentinel_entry = " \"plugins/xtop-plugin-sentinel\","; - let new_ws = if ws_content.contains(sentinel_entry) { - ws_content.replace( - sentinel_entry, - &format!("{sentinel_entry}\n{member_entry},"), - ) - } else { - // Fallback: insert before the closing ] of members - ws_content.replacen("]", &format!(" {member_entry},\n]"), 1) - }; - fs::write(&workspace_toml, &new_ws)?; - - // --- Add to xtop-cli Cargo.toml --- - let cli_content = fs::read_to_string(&cli_toml)?; - - // Build dependency path relative to crates/xtop-cli/ - let dep_path = format!("../../plugins/{plugin_dir_name}"); - let dep_line = format!("{pkg_name} = {{ path = \"{dep_path}\", optional = true }}"); - - if !cli_content.contains(&dep_line) { - // Find the last optional plugin dependency and insert after it - let marker = "# Optional plugins (behind feature flags)"; - let new_cli = cli_content.replace(marker, &format!("{marker}\n{dep_line}")); - fs::write(&cli_toml, &new_cli)?; - } - - // Add feature flag - let feature_line = format!("{feature_name} = [\"dep:{pkg_name}\"]"); - let cli_content2 = fs::read_to_string(&cli_toml)?; - if !cli_content2.contains(&feature_line) { - let sentinel_feature = "plugin-sentinel = [\"dep:xtop-plugin-sentinel\"]"; - let new_cli2 = if cli_content2.contains(sentinel_feature) { - cli_content2.replace( - sentinel_feature, - &format!("{sentinel_feature}\n{feature_line}"), - ) - } else { - cli_content2.replacen("[features]", &format!("[features]\n{feature_line}"), 1) - }; - fs::write(&cli_toml, &new_cli2)?; - } - - // --- Rebuild --- - println!("Building xtop with {pkg_name} ..."); - let build = std::process::Command::new("cargo") - .args(["build", "--release"]) - .current_dir(workspace_dir) - .status() - .map_err(|e| anyhow::anyhow!("cargo build failed: {e}"))?; - if !build.success() { - anyhow::bail!("Build failed. Check the plugin's compatibility."); - } - - // --- Cleanup --- - let _ = fs::remove_dir_all(&tmp); - - println!(); - println!("Plugin '{pkg_name}' installed successfully."); - println!(" Location: plugins/{plugin_dir_name}"); - println!(" Feature flag: {feature_name}"); - println!(); - println!("Note: '{feature_name}' is NOT enabled by default."); - println!("To enable it, add '{feature_name}' to the 'default' feature list"); - println!("in crates/xtop-cli/Cargo.toml, then rebuild."); - - Ok(()) -} - -fn cmd_plugin_scaffold(name: &str) -> anyhow::Result<()> { - let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); - let workspace_dir = manifest_dir.parent().unwrap().parent().unwrap(); - let plugins_dir = workspace_dir.join("plugins"); - let plugin_dir = plugins_dir.join(format!("xtop-plugin-{name}")); - - if plugin_dir.exists() { - anyhow::bail!("Plugin crate already exists at {}", plugin_dir.display()); - } - - let src_dir = plugin_dir.join("src"); - fs::create_dir_all(&src_dir)?; - - // Cargo.toml (path refs go up from plugins/ to workspace root, then into crates/) - let cargo_toml = format!( - r#"[package] -name = "xtop-plugin-{name}" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "xtop plugin: {name}" - -[dependencies] -xtop-core = {{ path = "../../crates/xtop-core" }} -ratatui.workspace = true -"# - ); - fs::write(plugin_dir.join("Cargo.toml"), &cargo_toml)?; - - // lib.rs - let lib_rs = format!( - r#"use xtop_core::domain::plugin::{{Plugin, PluginCapability, PluginContext, PluginError, PluginManifest}}; - -pub struct {name_cap}Plugin; - -impl {name_cap}Plugin {{ - pub fn new() -> Self {{ - Self - }} -}} - -impl Plugin for {name_cap}Plugin {{ - fn manifest(&self) -> PluginManifest {{ - PluginManifest {{ - id: "{name}".to_string(), - name: "{name_cap}".to_string(), - version: "0.1.0".to_string(), - description: "xtop plugin: {name}".to_string(), - capabilities: vec![PluginCapability::ReadSystemInfo], - }} - }} - - fn on_tick(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> {{ - Ok(()) - }} -}} -"#, - name = name, - name_cap = { - let mut chars = name.chars(); - match chars.next() { - None => String::new(), - Some(c) => c.to_uppercase().to_string() + chars.as_str(), - } - } - ); - fs::write(src_dir.join("lib.rs"), &lib_rs)?; - - println!("Plugin scaffold created at {}", plugin_dir.display()); - println!("To register it:"); - println!(" 1. Add \"plugins/xtop-plugin-{name}\" to [workspace].members in Cargo.toml"); - println!(" 2. Add dependency + feature flag in crates/xtop-cli/Cargo.toml"); - println!(" 3. Add #[cfg(feature = \"plugin-{name}\")] import in main.rs"); - println!(" 4. Implement Plugin trait methods"); - - Ok(()) -} - -fn cp_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> { - if src.is_dir() { - fs::create_dir_all(dst)?; - for entry in fs::read_dir(src)? { - let entry = entry?; - let file_type = entry.file_type()?; - let src_path = entry.path(); - let dst_path = dst.join(entry.file_name()); - if file_type.is_dir() { - // Skip .git directory - if entry.file_name() != ".git" { - cp_recursive(&src_path, &dst_path)?; - } - } else { - fs::copy(&src_path, &dst_path)?; - } - } - Ok(()) - } else { - fs::copy(src, dst)?; - Ok(()) - } -} - -fn main() -> anyhow::Result<()> { - let args: Vec = std::env::args().collect(); - - // CLI subcommands - if args.len() > 1 { - match args[1].as_str() { - "mcp" => { - ensure_default_assets(); - return mcp::run_mcp_server(); - } - "plugin" => { - if args.len() < 3 { - print_usage(); - return Ok(()); - } - match args[2].as_str() { - "list" => { - cmd_plugin_list(); - return Ok(()); - } - "install" => { - if args.len() < 4 { - eprintln!("Usage: xtop plugin install "); - return Ok(()); - } - return cmd_plugin_install(&args[3]); - } - "scaffold" => { - if args.len() < 4 { - eprintln!("Usage: xtop plugin scaffold "); - return Ok(()); - } - return cmd_plugin_scaffold(&args[3]); - } - _ => { - print_usage(); - return Ok(()); - } - } - } - "--help" | "-h" => { - print_usage(); - return Ok(()); - } - _ => {} - } - } - - ensure_default_assets(); - - terminal::install_panic_hook(); - let mut terminal = terminal::init()?; - - let cfg_dir = config_dir(); - - // Build the primary (composite) provider - let sysinfo_provider = SysinfoProvider::new(); - let composite = CompositeProvider::new(Box::new(sysinfo_provider)); - - let themes = load_all_themes(); - let cfg = config::load_config(); - let mut builtin_layouts = layout_loader::builtin_layouts(); - let custom_layouts = layout_loader::load_custom_layouts(); - builtin_layouts.extend(custom_layouts); - let mut state = AppState::new(Box::new(composite), themes, cfg, builtin_layouts); - - // Build and register plugins - let plugin_mgr = build_plugin_manager(&mut state, &cfg_dir); - - // Collect extra data providers from plugins and inject everything into state - let extra_providers = plugin_mgr.collect_data_providers(); - state.init_plugins(plugin_mgr, extra_providers); - - let tick_rate = Duration::from_millis(state.update_interval_ms); - let mut last_tick = Instant::now(); - - loop { - terminal.draw(|f| render::render(f, &state))?; - - let timeout = tick_rate - .checked_sub(last_tick.elapsed()) - .unwrap_or_default(); - - if event::poll(timeout)? { - if let Event::Key(key) = event::read()? { - let key_str = key_event_to_str(&key); - - // Give plugins first chance to consume the key - let key_str_clone = key_str.clone(); - let key_consumed = - state.with_plugin_manager_mut(|mgr, this| mgr.handle_key(this, &key_str_clone)); - if key_consumed { - continue; - } - - // DEBUG: print key for diagnostics - if cfg!(debug_assertions) && !key_str.is_empty() { - eprintln!("[key] '{key_str}'"); - } - - match state.input_mode { - InputMode::Normal => { - // Direct Ctrl+P check (works regardless of keybinding config, important on macOS) - if key_str == "ctrl+p" { - state.open_palette(); - state.input_mode = InputMode::CommandPalette; - } else if let Some(action) = state.keybindings.resolve(&key_str) { - match action { - Action::Quit => { - save_config(&state); - state.quit(); - } - Action::Cancel if state.show_help => { - state.toggle_help(); - } - Action::OpenCommandPalette => { - state.open_palette(); - state.input_mode = InputMode::CommandPalette; - } - Action::KillProcess | Action::ProcessUp | Action::ProcessDown => { - state.execute_action(&action); - } - _ => { - state.execute_action(&action); - } - } - } - } - InputMode::Searching => match key.code { - KeyCode::Esc => { - state.search_query.clear(); - state.end_search(); - } - KeyCode::Enter => { - state.end_search(); - } - KeyCode::Backspace => { - state.search_pop_char(); - } - KeyCode::Char(c) => { - state.search_push_char(c); - } - _ => {} - }, - InputMode::CommandPalette => { - let is_main = state.palette.page == PalettePage::Main; - match key.code { - KeyCode::Esc => { - state.close_palette(); - } - KeyCode::Enter => { - if let Some(action) = state.palette_selected_action() { - state.execute_action(&action); - save_config(&state); - } - } - KeyCode::Down => { - state.palette_select_next(); - } - KeyCode::Up => { - state.palette_select_prev(); - } - KeyCode::Char(c) => { - state.palette.query.push(c); - state.palette_filter(); - } - KeyCode::Backspace => { - if state.palette.query.is_empty() && !is_main { - state.palette_navigate_to(PalettePage::Main); - } else { - state.palette.query.pop(); - state.palette_filter(); - } - } - _ => {} - } - } - } - } - } - - if last_tick.elapsed() >= tick_rate { - state.on_tick(); - last_tick = Instant::now(); - } - - if state.should_quit { - break; - } - } - - // Disable plugins on shutdown - state.with_plugin_manager_mut(|mgr, this| { - mgr.disable_all(this); - }); - - terminal::restore()?; - Ok(()) -} diff --git a/crates/xtop-cli/src/mcp.rs b/crates/xtop-cli/src/mcp.rs deleted file mode 100644 index 9a7e39e..0000000 --- a/crates/xtop-cli/src/mcp.rs +++ /dev/null @@ -1,77 +0,0 @@ -//! MCP server entry point. -//! -//! Initializes xtop state and plugins, then delegates to the Sentinel plugin's -//! MCP module (`xtop_plugin_sentinel::mcp::run_server`) which handles the -//! actual stdin/stdout MCP protocol loop. - -use std::path::{Path, PathBuf}; -use xtop_core::application::plugin_manager::PluginManager; -use xtop_core::application::state::AppState; -use xtop_core::infrastructure::composite_provider::CompositeProvider; -use xtop_core::infrastructure::config; -use xtop_core::infrastructure::layout_loader; -use xtop_core::infrastructure::sysinfo_provider::SysinfoProvider; -use xtop_core::infrastructure::theme_loader::load_all_themes; - -#[cfg(feature = "plugin-sentinel")] -use xtop_plugin_sentinel::SentinelPlugin; - -/// Run the MCP server. -/// -/// Sets up AppState + PluginManager with Sentinel, then delegates to -/// the plugin's MCP module for the protocol loop. -pub fn run_mcp_server() -> anyhow::Result<()> { - let cfg_dir = config_dir(); - let mut state = initialize_state(&cfg_dir)?; - - // Delegate to Sentinel's MCP module - #[cfg(feature = "plugin-sentinel")] - { - xtop_plugin_sentinel::mcp::run_server(&mut state) - } - - #[cfg(not(feature = "plugin-sentinel"))] - { - eprintln!("MCP server requires the 'plugin-sentinel' feature."); - eprintln!("Rebuild with: cargo build --features plugin-sentinel"); - std::process::exit(1); - } -} - -fn config_dir() -> PathBuf { - xtop_core::infrastructure::config::config_dir() -} - -fn build_plugin_manager(state: &mut AppState, cfg_dir: &Path) -> PluginManager { - let plugins_dir = cfg_dir.join("plugins"); - std::fs::create_dir_all(&plugins_dir).ok(); - let mut mgr = PluginManager::new(plugins_dir); - - #[cfg(feature = "plugin-sentinel")] - { - let plugin = Box::new(SentinelPlugin::new()); - if let Err(e) = mgr.register(plugin, state) { - eprintln!("[xtop-mcp] failed to load sentinel plugin: {e}"); - } - } - - mgr -} - -fn initialize_state(cfg_dir: &Path) -> anyhow::Result { - let sysinfo_provider = SysinfoProvider::new(); - let composite = CompositeProvider::new(Box::new(sysinfo_provider)); - - let themes = load_all_themes(); - let cfg = config::load_config(); - let mut builtin_layouts = layout_loader::builtin_layouts(); - let custom_layouts = layout_loader::load_custom_layouts(); - builtin_layouts.extend(custom_layouts); - let mut state = AppState::new(Box::new(composite), themes, cfg, builtin_layouts); - - let plugin_mgr = build_plugin_manager(&mut state, cfg_dir); - let extra_providers = plugin_mgr.collect_data_providers(); - state.init_plugins(plugin_mgr, extra_providers); - - Ok(state) -} diff --git a/crates/xtop-core/Cargo.toml b/crates/xtop-core/Cargo.toml deleted file mode 100644 index 862b526..0000000 --- a/crates/xtop-core/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "xtop-core" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -sysinfo.workspace = true -serde.workspace = true -serde_json.workspace = true -ratatui.workspace = true diff --git a/crates/xtop-core/src/application/mod.rs b/crates/xtop-core/src/application/mod.rs deleted file mode 100644 index 9e59902..0000000 --- a/crates/xtop-core/src/application/mod.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod history; -pub mod plugin_manager; -pub mod state; diff --git a/crates/xtop-core/src/application/state.rs b/crates/xtop-core/src/application/state.rs deleted file mode 100644 index 80ae584..0000000 --- a/crates/xtop-core/src/application/state.rs +++ /dev/null @@ -1,851 +0,0 @@ -use crate::application::history::MetricsHistory; -use crate::application::plugin_manager::PluginManager; -use crate::domain::keybinding::{Action, Keybindings}; -use crate::domain::layout::LayoutDef; -use crate::domain::metrics::SystemInfo; -use crate::domain::metrics::SystemSnapshot; -use crate::domain::plugin::WidgetRegistration; -use crate::domain::system_info::SystemDataProvider; -use crate::domain::theme::Theme; -use serde::{Deserialize, Serialize}; - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -pub enum LayoutMode { - Dashboard, - Vertical, - Horizontal, - CpuFocus, - MemoryFocus, - NetworkFocus, - ProcessFocus, -} - -impl LayoutMode { - pub fn next(self) -> Self { - match self { - Self::Dashboard => Self::Vertical, - Self::Vertical => Self::Horizontal, - Self::Horizontal => Self::CpuFocus, - Self::CpuFocus => Self::MemoryFocus, - Self::MemoryFocus => Self::NetworkFocus, - Self::NetworkFocus => Self::ProcessFocus, - Self::ProcessFocus => Self::Dashboard, - } - } - - pub fn label(self) -> &'static str { - match self { - Self::Dashboard => "Dashboard", - Self::Vertical => "Vertical", - Self::Horizontal => "Horizontal", - Self::CpuFocus => "CPU Focus", - Self::MemoryFocus => "Memory Focus", - Self::NetworkFocus => "Network Focus", - Self::ProcessFocus => "Process Focus", - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum EffectiveLayout { - Dashboard, - Compact, - Vertical, - Horizontal, - CpuFocus, - MemoryFocus, - NetworkFocus, - ProcessFocus, - Minimal, -} - -fn layout_index_from_mode(mode: LayoutMode, defs: &[LayoutDef]) -> usize { - let label = mode.label(); - defs.iter().position(|d| d.name == label).unwrap_or(0) -} - -fn mode_from_layout_index(index: usize) -> LayoutMode { - match index { - 0 => LayoutMode::Dashboard, - 1 => LayoutMode::Vertical, - 2 => LayoutMode::Horizontal, - 3 => LayoutMode::CpuFocus, - 4 => LayoutMode::MemoryFocus, - 5 => LayoutMode::NetworkFocus, - 6 => LayoutMode::ProcessFocus, - _ => LayoutMode::Dashboard, - } -} - -pub fn detect_effective_layout(width: u16, height: u16, user_mode: LayoutMode) -> EffectiveLayout { - if width < 60 || height < 14 { - return EffectiveLayout::Minimal; - } - match user_mode { - LayoutMode::Dashboard => { - if width < 80 { - EffectiveLayout::Vertical - } else if width < 100 || height < 28 { - EffectiveLayout::Compact - } else { - EffectiveLayout::Dashboard - } - } - LayoutMode::Vertical => EffectiveLayout::Vertical, - LayoutMode::Horizontal => EffectiveLayout::Horizontal, - LayoutMode::CpuFocus => EffectiveLayout::CpuFocus, - LayoutMode::MemoryFocus => EffectiveLayout::MemoryFocus, - LayoutMode::NetworkFocus => EffectiveLayout::NetworkFocus, - LayoutMode::ProcessFocus => EffectiveLayout::ProcessFocus, - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum FullScreenWidget { - None, - Cpu, - Memory, - Storage, - Network, - Processes, - DiskIO, - Gpu, - Battery, -} - -#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] -pub enum ProcessSortBy { - Cpu, - Memory, - Pid, - Name, -} - -impl ProcessSortBy { - pub fn next(self) -> Self { - match self { - Self::Cpu => Self::Memory, - Self::Memory => Self::Pid, - Self::Pid => Self::Name, - Self::Name => Self::Cpu, - } - } - - pub fn label(self) -> &'static str { - match self { - Self::Cpu => "CPU%", - Self::Memory => "Mem", - Self::Pid => "PID", - Self::Name => "Name", - } - } -} - -impl FullScreenWidget { - pub fn next(self) -> Self { - match self { - Self::None => Self::Cpu, - Self::Cpu => Self::Memory, - Self::Memory => Self::Storage, - Self::Storage => Self::Network, - Self::Network => Self::Processes, - Self::Processes => Self::DiskIO, - Self::DiskIO => Self::Gpu, - Self::Gpu => Self::Battery, - Self::Battery => Self::None, - } - } - - pub fn label(self) -> &'static str { - match self { - Self::None => "", - Self::Cpu => "CPU", - Self::Memory => "Memory", - Self::Storage => "Storage", - Self::Network => "Network", - Self::Processes => "Processes", - Self::DiskIO => "Disk I/O", - Self::Gpu => "GPU", - Self::Battery => "Battery", - } - } -} - -#[derive(Clone, Debug, PartialEq)] -pub struct PaletteEntry { - pub label: String, - pub action: Action, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum PalettePage { - Main, - Themes, - Layouts, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct PaletteState { - pub open: bool, - pub query: String, - pub selected: usize, - pub entries: Vec, - pub filtered: Vec, - pub page: PalettePage, -} - -impl PaletteState { - pub fn filtered_entries(&self) -> Vec<&PaletteEntry> { - self.filtered.iter().map(|&i| &self.entries[i]).collect() - } - - pub fn title(&self) -> &str { - match self.page { - PalettePage::Main => "Command Palette", - PalettePage::Themes => "Select Theme", - PalettePage::Layouts => "Select Layout", - } - } -} - -#[derive(Clone, Copy, Debug, PartialEq)] -pub enum InputMode { - Normal, - Searching, - CommandPalette, -} - -#[derive(Copy, Clone, Debug, Serialize, Deserialize)] -pub struct AlertThresholds { - pub cpu_high: f64, - pub mem_high: f64, - pub disk_high: f64, -} - -impl Default for AlertThresholds { - fn default() -> Self { - Self { - cpu_high: 90.0, - mem_high: 90.0, - disk_high: 90.0, - } - } -} - -fn default_layout_mode() -> LayoutMode { - LayoutMode::Dashboard -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct Config { - pub theme: String, - #[serde(default = "default_layout_mode")] - pub layout_mode: LayoutMode, - /// Layout name for custom layouts beyond the 7 built-in LayoutMode variants. - /// If non-empty, takes precedence over `layout_mode`. - #[serde(default)] - pub layout_name: String, - pub update_interval_ms: u64, - pub history_points: usize, - pub alerts: AlertThresholds, - #[serde(default)] - pub keybindings: Keybindings, -} - -impl Default for Config { - fn default() -> Self { - Self { - theme: "x".to_string(), - layout_mode: LayoutMode::Dashboard, - layout_name: String::new(), - update_interval_ms: 1000, - history_points: 100, - alerts: AlertThresholds::default(), - keybindings: Keybindings::default(), - } - } -} - -pub struct AppState { - provider: Box, - pub history: MetricsHistory, - pub should_quit: bool, - pub layout_mode: LayoutMode, - pub layout_index: usize, - pub layout_defs: Vec, - pub current_theme: Theme, - pub themes: Vec, - pub selected_theme_index: usize, - pub tick_count: f64, - pub show_help: bool, - pub input_mode: InputMode, - pub search_query: String, - pub full_screen_widget: FullScreenWidget, - pub alerts: AlertThresholds, - pub update_interval_ms: u64, - pub config_path: String, - pub palette: PaletteState, - pub keybindings: Keybindings, - pub process_sort: ProcessSortBy, - pub process_selected: Option, - pub sys_info: SystemInfo, - pub plugin_manager: Option, - pub plugin_widgets: Vec, -} - -impl AppState { - pub fn new( - provider: Box, - themes: Vec, - config: Config, - layout_defs: Vec, - ) -> Self { - let selected_theme_index = themes - .iter() - .position(|t| t.name == config.theme) - .unwrap_or(0); - let current_theme = themes[selected_theme_index].clone(); - let layout_index = if !config.layout_name.is_empty() { - layout_defs - .iter() - .position(|l| l.name == config.layout_name) - .unwrap_or_else(|| layout_index_from_mode(config.layout_mode, &layout_defs)) - } else { - layout_index_from_mode(config.layout_mode, &layout_defs) - }; - Self { - provider, - history: MetricsHistory::new(config.history_points), - should_quit: false, - layout_mode: config.layout_mode, - layout_index, - layout_defs, - current_theme, - themes, - selected_theme_index, - tick_count: 0.0, - show_help: false, - input_mode: InputMode::Normal, - search_query: String::new(), - full_screen_widget: FullScreenWidget::None, - alerts: config.alerts, - update_interval_ms: config.update_interval_ms, - config_path: String::new(), - palette: PaletteState { - open: false, - query: String::new(), - selected: 0, - entries: Vec::new(), - filtered: Vec::new(), - page: PalettePage::Main, - }, - keybindings: config.keybindings, - process_sort: ProcessSortBy::Cpu, - process_selected: None, - sys_info: SystemInfo::default(), - plugin_manager: None, - plugin_widgets: Vec::new(), - } - } - - /// Set the plugin manager and inject extra data providers into the composite provider. - /// Called once during initialization after all plugins are registered. - pub fn init_plugins( - &mut self, - mgr: PluginManager, - extra_providers: Vec>, - ) { - if !extra_providers.is_empty() { - self.provider.add_extras(extra_providers); - } - self.plugin_manager = Some(mgr); - self.refresh_plugin_widgets(); - } - - /// Collect plugin widgets into the state for the TUI renderer. - pub fn refresh_plugin_widgets(&mut self) { - if let Some(ref mgr) = self.plugin_manager { - let registrations = mgr.collect_widgets(); - // Only accept if the plugin has RenderWidgets capability - self.plugin_widgets = registrations; - } - } - - /// Kill a process by PID. Returns true if the signal was sent. - pub fn kill_process_by_pid(&mut self, pid: u32) -> bool { - self.provider.kill_process(pid) - } - - /// Set alert thresholds. - pub fn set_alert_thresholds(&mut self, cpu: f64, mem: f64, disk: f64) { - self.alerts = AlertThresholds { - cpu_high: cpu, - mem_high: mem, - disk_high: disk, - }; - } - - /// Switch to a theme by name. Returns true if found. - pub fn set_theme_by_name(&mut self, name: &str) -> bool { - if let Some(idx) = self.themes.iter().position(|t| t.name == name) { - self.selected_theme_index = idx; - self.apply_theme(); - true - } else { - false - } - } - - /// Switch to a layout by name. Returns true if found. - pub fn set_layout_by_name(&mut self, name: &str) -> bool { - if let Some(idx) = self.layout_defs.iter().position(|l| l.name == name) { - self.layout_index = idx; - self.layout_mode = self.save_layout_mode(); - self.full_screen_widget = FullScreenWidget::None; - true - } else { - false - } - } - - pub fn current_layout(&self) -> &LayoutDef { - &self.layout_defs[self.layout_index] - } - - pub fn save_layout_mode(&self) -> LayoutMode { - mode_from_layout_index(self.layout_index) - } - - /// Safely access the plugin manager with a closure. - /// Ensures the plugin manager is always restored after the operation. - /// NOTE: does NOT call refresh_plugin_widgets — the caller must do it if needed. - pub fn with_plugin_manager_mut( - &mut self, - f: impl FnOnce(&mut PluginManager, &mut Self) -> R, - ) -> R { - let mut mgr = self - .plugin_manager - .take() - .expect("PluginManager not initialized"); - let result = f(&mut mgr, self); - self.plugin_manager = Some(mgr); - result - } - - pub fn on_tick(&mut self) { - self.provider.refresh_all(); - self.tick_count += 1.0; - let info = self.provider.system_info(); - if !info.hostname.is_empty() { - self.sys_info = info; - } - let x = self.tick_count; - let snap = self.provider.snapshot(); - - if snap.cpus.len() != self.history.cpu.len() { - self.history.reset_cpu(snap.cpus.len()); - } - for (i, cpu) in snap.cpus.iter().enumerate() { - self.history.push_cpu(i, x, cpu.usage); - } - - self.history.push_mem(x, snap.memory.percent); - - let total_rx: u64 = snap.networks.iter().map(|n| n.received).sum(); - let total_tx: u64 = snap.networks.iter().map(|n| n.transmitted).sum(); - self.history.push_net(x, total_rx as f64, total_tx as f64); - - // Let plugins tick - self.with_plugin_manager_mut(|mgr, this| { - mgr.tick_all(this); - }); - self.refresh_plugin_widgets(); - } - - pub fn snapshot(&self) -> SystemSnapshot { - let mut snap = self.provider.snapshot(); - snap.disk_io = self.provider.disk_io(); - snap.batteries = self.provider.batteries(); - snap.gpus = self.provider.gpu_info(); - snap.dockers = self.provider.docker_info(); - snap.sys_info = self.provider.system_info(); - snap - } - - pub fn next_theme(&mut self) { - self.selected_theme_index = (self.selected_theme_index + 1) % self.themes.len(); - self.apply_theme(); - } - - pub fn previous_theme(&mut self) { - if self.selected_theme_index == 0 { - self.selected_theme_index = self.themes.len() - 1; - } else { - self.selected_theme_index -= 1; - } - self.apply_theme(); - } - - fn apply_theme(&mut self) { - self.current_theme = self.themes[self.selected_theme_index].clone(); - } - - pub fn next_layout(&mut self) { - self.layout_index = (self.layout_index + 1) % self.layout_defs.len(); - self.layout_mode = self.save_layout_mode(); - self.full_screen_widget = FullScreenWidget::None; - } - - pub fn current_layout_name(&self) -> &str { - &self.layout_defs[self.layout_index].name - } - - pub fn toggle_fullscreen(&mut self) { - self.full_screen_widget = match self.full_screen_widget { - FullScreenWidget::None => FullScreenWidget::Cpu, - _ => FullScreenWidget::None, - }; - } - - pub fn cycle_fullscreen_widget(&mut self) { - self.full_screen_widget = self.full_screen_widget.next(); - } - - pub fn start_search(&mut self) { - self.input_mode = InputMode::Searching; - self.search_query.clear(); - } - - pub fn search_push_char(&mut self, c: char) { - self.search_query.push(c); - } - - pub fn search_pop_char(&mut self) { - self.search_query.pop(); - } - - pub fn end_search(&mut self) { - self.input_mode = InputMode::Normal; - } - - pub fn toggle_help(&mut self) { - self.show_help = !self.show_help; - } - - pub fn quit(&mut self) { - self.should_quit = true; - } - - pub fn rebuild_palette(&mut self) { - self.palette.entries.clear(); - match self.palette.page { - PalettePage::Main => { - self.palette.entries.push(PaletteEntry { - label: "Themes →".into(), - action: Action::NavigateThemes, - }); - self.palette.entries.push(PaletteEntry { - label: "Layouts →".into(), - action: Action::NavigateLayouts, - }); - self.palette.entries.push(PaletteEntry { - label: "Toggle Fullscreen".into(), - action: Action::ToggleFullscreen, - }); - self.palette.entries.push(PaletteEntry { - label: "Cycle Fullscreen Widget".into(), - action: Action::CycleFullscreen, - }); - self.palette.entries.push(PaletteEntry { - label: "Search Processes".into(), - action: Action::Search, - }); - self.palette.entries.push(PaletteEntry { - label: "Toggle Help".into(), - action: Action::ToggleHelp, - }); - self.palette.entries.push(PaletteEntry { - label: format!("Sort: {}", self.process_sort.label()), - action: Action::SortByCpu, - }); - self.palette.entries.push(PaletteEntry { - label: "Random Theme".into(), - action: Action::RandomTheme, - }); - self.palette.entries.push(PaletteEntry { - label: "Exit".into(), - action: Action::Quit, - }); - } - PalettePage::Themes => { - for (i, theme) in self.themes.iter().enumerate() { - self.palette.entries.push(PaletteEntry { - label: theme.name.clone(), - action: Action::SelectTheme(i), - }); - } - } - PalettePage::Layouts => { - for (i, layout) in self.layout_defs.iter().enumerate() { - self.palette.entries.push(PaletteEntry { - label: layout.name.clone(), - action: Action::SelectLayout(i), - }); - } - } - } - self.palette_filter(); - } - - pub fn open_palette(&mut self) { - self.palette.open = true; - self.palette.query.clear(); - self.palette.selected = 0; - self.palette.page = PalettePage::Main; - self.rebuild_palette(); - } - - pub fn palette_navigate_to(&mut self, page: PalettePage) { - self.palette.page = page; - self.palette.query.clear(); - self.palette.selected = 0; - self.rebuild_palette(); - } - - pub fn palette_filter(&mut self) { - let q = self.palette.query.to_lowercase(); - self.palette.filtered = self - .palette - .entries - .iter() - .enumerate() - .filter(|(_, e)| q.is_empty() || e.label.to_lowercase().contains(&q)) - .map(|(i, _)| i) - .collect(); - if !self.palette.filtered.is_empty() { - self.palette.selected = self.palette.selected.min(self.palette.filtered.len() - 1); - } else { - self.palette.selected = 0; - } - } - - pub fn palette_select_next(&mut self) { - if !self.palette.filtered.is_empty() { - self.palette.selected = (self.palette.selected + 1) % self.palette.filtered.len(); - } - } - - pub fn palette_select_prev(&mut self) { - if !self.palette.filtered.is_empty() { - self.palette.selected = if self.palette.selected == 0 { - self.palette.filtered.len() - 1 - } else { - self.palette.selected - 1 - }; - } - } - - pub fn process_select_next(&mut self) { - let snap = self.snapshot(); - if snap.processes.is_empty() { - return; - } - let idx = self.process_selected.unwrap_or(0); - self.process_selected = Some((idx + 1) % snap.processes.len()); - } - - pub fn process_select_prev(&mut self) { - let snap = self.snapshot(); - if snap.processes.is_empty() { - return; - } - let idx = self.process_selected.unwrap_or(0); - self.process_selected = Some(if idx == 0 { - snap.processes.len() - 1 - } else { - idx - 1 - }); - } - - pub fn cycle_sort(&mut self) { - self.process_sort = self.process_sort.next(); - self.process_selected = None; - } - - pub fn palette_selected_action(&self) -> Option { - self.palette - .filtered - .get(self.palette.selected) - .and_then(|&i| self.palette.entries.get(i)) - .map(|e| e.action.clone()) - } - - pub fn close_palette(&mut self) { - self.palette.open = false; - self.palette.page = PalettePage::Main; - self.input_mode = InputMode::Normal; - } - - pub fn execute_action(&mut self, action: &Action) { - match action { - Action::Quit => self.quit(), - Action::ToggleHelp => self.toggle_help(), - Action::NextTheme => self.next_theme(), - Action::PreviousTheme => self.previous_theme(), - Action::NextLayout => self.next_layout(), - Action::ToggleFullscreen => self.toggle_fullscreen(), - Action::CycleFullscreen => self.cycle_fullscreen_widget(), - Action::Search => self.start_search(), - Action::OpenCommandPalette => {} - Action::Cancel => { - if self.show_help { - self.toggle_help(); - } - } - Action::SelectTheme(i) => { - self.selected_theme_index = *i; - self.apply_theme(); - } - Action::SelectLayout(i) => { - self.layout_index = *i; - self.layout_mode = self.save_layout_mode(); - self.full_screen_widget = FullScreenWidget::None; - } - Action::NavigateThemes => { - self.palette_navigate_to(PalettePage::Themes); - return; - } - Action::NavigateLayouts => { - self.palette_navigate_to(PalettePage::Layouts); - return; - } - Action::KillProcess => { - if let Some(pid) = self.process_selected { - let snap = self.snapshot(); - if pid < snap.processes.len() { - let target = snap.processes[pid].pid; - self.provider.kill_process(target); - self.process_selected = None; - } - } - } - Action::ProcessUp => self.process_select_prev(), - Action::ProcessDown => self.process_select_next(), - Action::SortByPid | Action::SortByCpu | Action::SortByName | Action::SortByMem => { - self.cycle_sort(); - } - Action::RandomTheme => { - let n = self.themes.len(); - if n > 1 { - let next = (self.selected_theme_index + 7) % n; - self.selected_theme_index = next; - self.apply_theme(); - } - } - } - // Close palette after executing any action (except navigation which returns above) - if self.input_mode == InputMode::CommandPalette { - self.close_palette(); - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_layout_mode_next() { - assert_eq!(LayoutMode::Dashboard.next(), LayoutMode::Vertical); - assert_eq!(LayoutMode::Vertical.next(), LayoutMode::Horizontal); - assert_eq!(LayoutMode::Horizontal.next(), LayoutMode::CpuFocus); - assert_eq!(LayoutMode::CpuFocus.next(), LayoutMode::MemoryFocus); - assert_eq!(LayoutMode::MemoryFocus.next(), LayoutMode::NetworkFocus); - assert_eq!(LayoutMode::NetworkFocus.next(), LayoutMode::ProcessFocus); - assert_eq!(LayoutMode::ProcessFocus.next(), LayoutMode::Dashboard); - } - - #[test] - fn test_detect_effective_layout_large() { - assert_eq!( - detect_effective_layout(120, 40, LayoutMode::Dashboard), - EffectiveLayout::Dashboard - ); - } - - #[test] - fn test_detect_effective_layout_compact() { - assert_eq!( - detect_effective_layout(90, 30, LayoutMode::Dashboard), - EffectiveLayout::Compact - ); - } - - #[test] - fn test_detect_effective_layout_narrow() { - assert_eq!( - detect_effective_layout(70, 30, LayoutMode::Dashboard), - EffectiveLayout::Vertical - ); - } - - #[test] - fn test_detect_effective_layout_minimal() { - assert_eq!( - detect_effective_layout(50, 15, LayoutMode::Dashboard), - EffectiveLayout::Minimal - ); - } - - #[test] - fn test_detect_effective_layout_focus_respected() { - assert_eq!( - detect_effective_layout(80, 30, LayoutMode::CpuFocus), - EffectiveLayout::CpuFocus - ); - assert_eq!( - detect_effective_layout(80, 30, LayoutMode::NetworkFocus), - EffectiveLayout::NetworkFocus - ); - } - - #[test] - fn test_detect_effective_layout_focus_downgrade() { - assert_eq!( - detect_effective_layout(50, 30, LayoutMode::CpuFocus), - EffectiveLayout::Minimal - ); - } - - #[test] - fn test_fullscreen_widget_cycle() { - assert_eq!(FullScreenWidget::None.next(), FullScreenWidget::Cpu); - assert_eq!(FullScreenWidget::Battery.next(), FullScreenWidget::None); - } - - #[test] - fn test_layout_mode_label() { - assert_eq!(LayoutMode::Dashboard.label(), "Dashboard"); - assert_eq!(LayoutMode::CpuFocus.label(), "CPU Focus"); - assert_eq!(LayoutMode::Horizontal.label(), "Horizontal"); - } - - #[test] - fn test_alert_thresholds_default() { - let a = AlertThresholds::default(); - assert_eq!(a.cpu_high, 90.0); - assert_eq!(a.mem_high, 90.0); - assert_eq!(a.disk_high, 90.0); - } - - #[test] - fn test_config_default() { - let c = Config::default(); - assert_eq!(c.theme, "x"); - assert_eq!(c.layout_mode, LayoutMode::Dashboard); - assert_eq!(c.update_interval_ms, 1000); - } - - #[test] - fn test_search_operations() {} -} diff --git a/crates/xtop-core/src/domain/layout.rs b/crates/xtop-core/src/domain/layout.rs deleted file mode 100644 index cceca80..0000000 --- a/crates/xtop-core/src/domain/layout.rs +++ /dev/null @@ -1,185 +0,0 @@ -use serde::de::{self, MapAccess, Visitor}; -use serde::Deserialize; -use std::fmt; - -#[derive(Clone, Debug, PartialEq)] -pub enum Direction { - Horizontal, - Vertical, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum LayoutConstraint { - Length(u16), - Percentage(u16), - Fill, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct LayoutArea { - pub constraint: LayoutConstraint, - pub node: LayoutNode, -} - -#[derive(Clone, Debug, PartialEq)] -pub enum LayoutNode { - Split { - direction: Direction, - areas: Vec, - }, - Widget { - name: String, - }, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct LayoutDef { - pub name: String, - pub root: LayoutNode, -} - -// --------------------------------------------------------------------------- -// Deserialization helpers -// --------------------------------------------------------------------------- - -#[derive(Deserialize)] -struct LayoutDefRaw { - name: String, - root: LayoutAreaRaw, -} - -#[derive(Deserialize)] -struct LayoutAreaRaw { - #[serde(default)] - size: Option, - widget: Option, - direction: Option, - #[serde(default)] - areas: Option>, -} - -#[derive(Deserialize)] -#[serde(untagged)] -enum SizeRaw { - Num(u16), - Str(String), -} - -impl TryFrom for LayoutArea { - type Error = String; - - fn try_from(raw: LayoutAreaRaw) -> Result { - let constraint = match raw.size { - None => LayoutConstraint::Fill, - Some(SizeRaw::Num(n)) => LayoutConstraint::Length(n), - Some(SizeRaw::Str(s)) if s == "*" => LayoutConstraint::Fill, - Some(SizeRaw::Str(s)) if s.ends_with('%') => { - let pct = s - .trim_end_matches('%') - .parse::() - .map_err(|_| format!("invalid percentage: {s}"))?; - LayoutConstraint::Percentage(pct) - } - Some(SizeRaw::Str(s)) => { - return Err(format!("invalid size constraint: {s}")); - } - }; - - let node = if let Some(name) = raw.widget { - LayoutNode::Widget { name } - } else if let Some(dir) = raw.direction { - let direction = match dir.to_lowercase().as_str() { - "horizontal" => Direction::Horizontal, - "vertical" => Direction::Vertical, - _ => return Err(format!("invalid direction: {dir}")), - }; - let areas_raw = raw.areas.unwrap_or_default(); - let mut areas = Vec::with_capacity(areas_raw.len()); - for a in areas_raw { - areas.push(a.try_into()?); - } - LayoutNode::Split { direction, areas } - } else { - return Err("layout area must have 'widget' or 'direction'".into()); - }; - - Ok(LayoutArea { constraint, node }) - } -} - -impl TryFrom for LayoutDef { - type Error = String; - - fn try_from(raw: LayoutDefRaw) -> Result { - let area: LayoutArea = raw.root.try_into()?; - Ok(LayoutDef { - name: raw.name, - root: area.node, - }) - } -} - -// Custom Deserialize for LayoutDef (handles jsonc-compatible parsing) -impl<'de> Deserialize<'de> for LayoutDef { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - #[derive(Deserialize)] - #[serde(field_identifier, rename_all = "snake_case")] - enum Field { - Name, - Root, - } - - struct LayoutVisitor; - impl<'de> Visitor<'de> for LayoutVisitor { - type Value = LayoutDef; - - fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { - f.write_str("struct LayoutDef") - } - - fn visit_map(self, mut map: V) -> Result - where - V: MapAccess<'de>, - { - let mut raw = LayoutDefRaw { - name: String::new(), - root: LayoutAreaRaw { - size: None, - widget: None, - direction: None, - areas: None, - }, - }; - let mut found_name = false; - let mut found_root = false; - - while let Some(key) = map.next_key::()? { - match key { - Field::Name => { - raw.name = map.next_value::()?; - found_name = true; - } - Field::Root => { - raw.root = map.next_value::()?; - found_root = true; - } - } - } - - if !found_name { - return Err(de::Error::missing_field("name")); - } - if !found_root { - return Err(de::Error::missing_field("root")); - } - - LayoutDef::try_from(raw).map_err(de::Error::custom) - } - } - - deserializer.deserialize_struct("LayoutDef", &["name", "root"], LayoutVisitor) - } -} diff --git a/crates/xtop-core/src/domain/metrics.rs b/crates/xtop-core/src/domain/metrics.rs deleted file mode 100644 index a6f40d4..0000000 --- a/crates/xtop-core/src/domain/metrics.rs +++ /dev/null @@ -1,166 +0,0 @@ -#![allow(clippy::manual_non_exhaustive)] - -#[derive(Debug, Clone)] -pub struct CpuInfo { - pub name: String, - pub usage: f64, - pub cpu_id: usize, - pub frequency: u64, - pub governor: String, -} - -#[derive(Debug, Clone)] -pub struct MemoryInfo { - pub total: u64, - pub used: u64, - pub available: u64, - pub free: u64, - pub percent: f64, -} - -#[derive(Debug, Clone)] -pub struct SwapInfo { - pub total: u64, - pub used: u64, - pub free: u64, - pub percent: f64, -} - -#[derive(Debug, Clone)] -pub struct DiskInfo { - pub mount_point: String, - pub total_space: u64, - pub available_space: u64, - pub used_space: u64, - pub percent: f64, - pub file_system: String, - pub mount_options: String, -} - -#[derive(Debug, Clone)] -pub struct DiskIOInfo { - pub name: String, - pub read_bytes: u64, - pub write_bytes: u64, - pub read_speed: f64, - pub write_speed: f64, -} - -#[derive(Debug, Clone)] -pub struct NetworkInfo { - pub name: String, - pub received: u64, - pub transmitted: u64, - pub rx_speed: f64, - pub tx_speed: f64, - pub ip: Vec, -} - -#[derive(Debug, Clone)] -pub struct ProcessInfo { - pub pid: u32, - pub name: String, - pub cpu_usage: f64, - pub memory: u64, - pub user_id: Option, - pub state: String, - pub cmd: String, - - // P0 -- Malicious process detection essentials - /// Full path to the executable on disk - pub exe_path: Option, - /// Parent process ID - pub parent_pid: Option, - /// Full command-line argument vector (argv) - pub cmd_full: Vec, - - // P1 -- Timing, privilege, and context - /// Process start time as epoch seconds - pub start_time: u64, - /// Seconds since process started - pub run_time: u64, - /// Effective user ID (may differ from uid on SUID binaries) - pub effective_user_id: Option, - /// Group ID - pub group_id: Option, - /// Process working directory - pub cwd: Option, - /// Number of threads - pub thread_count: u64, - - // P2 -- I/O, environment, session - /// Number of open file descriptors - pub open_files: u64, - /// Max allowed file descriptors - pub open_files_limit: u64, - /// Total bytes read from disk by this process - pub disk_total_read_bytes: u64, - /// Total bytes written to disk by this process - pub disk_total_write_bytes: u64, - /// Environment variables - pub environ: Vec, - /// Session ID - pub session_id: Option, -} - -#[derive(Debug, Clone)] -pub struct LoadAvg { - pub one: f64, - pub five: f64, - pub fifteen: f64, -} - -#[derive(Debug, Clone)] -pub struct BatteryInfo { - pub name: String, - pub percentage: f32, - pub state: String, - pub time_to_full: Option, - pub time_to_empty: Option, - pub health: f32, - pub cycle_count: Option, -} - -#[derive(Debug, Clone)] -pub struct GpuInfo { - pub name: String, - pub usage: f64, - pub temperature: f32, - pub memory_total: u64, - pub memory_used: u64, -} - -#[derive(Debug, Clone)] -pub struct DockerInfo { - pub name: String, - pub status: String, - pub cpu_usage: f64, - pub memory_usage: u64, -} - -#[derive(Debug, Clone, Default)] -pub struct SystemInfo { - pub hostname: String, - pub os_version: String, - pub kernel: String, - pub desktop_env: String, - pub shell: String, -} - -#[derive(Debug, Clone)] -pub struct SystemSnapshot { - pub cpus: Vec, - pub memory: MemoryInfo, - pub swap: SwapInfo, - pub disks: Vec, - pub networks: Vec, - pub processes: Vec, - pub load_avg: LoadAvg, - pub uptime: u64, - pub cpu_temp: f64, - pub disk_io: Vec, - pub batteries: Vec, - pub gpus: Vec, - pub dockers: Vec, - pub sys_info: SystemInfo, -} diff --git a/crates/xtop-core/src/domain/mod.rs b/crates/xtop-core/src/domain/mod.rs deleted file mode 100644 index 35bf2ef..0000000 --- a/crates/xtop-core/src/domain/mod.rs +++ /dev/null @@ -1,6 +0,0 @@ -pub mod keybinding; -pub mod layout; -pub mod metrics; -pub mod plugin; -pub mod system_info; -pub mod theme; diff --git a/crates/xtop-core/src/domain/plugin.rs b/crates/xtop-core/src/domain/plugin.rs deleted file mode 100644 index 22755dd..0000000 --- a/crates/xtop-core/src/domain/plugin.rs +++ /dev/null @@ -1,220 +0,0 @@ -use std::fmt::Debug; - -use crate::application::state::AppState; -use crate::domain::metrics::SystemSnapshot; -use crate::domain::system_info::SystemDataProvider; - -/// Unique identifier for a plugin capability. -/// Used for permission checking and manifest declaration. -#[derive(Clone, Debug, PartialEq)] -#[non_exhaustive] -pub enum PluginCapability { - /// Read system metrics (CPU, memory, network, disks, processes) - ReadSystemInfo, - /// Terminate processes - KillProcesses, - /// Modify configuration (themes, layouts, alerts, interval) - ModifyConfig, - /// Register custom widgets in the TUI - RenderWidgets, - /// Anything not covered above - Custom(String), -} - -/// Static metadata about a plugin. -/// Returned by [`Plugin::manifest`]. -#[derive(Clone, Debug)] -pub struct PluginManifest { - pub id: String, - pub name: String, - pub version: String, - pub description: String, - pub capabilities: Vec, -} - -/// Error type for plugin operations. -#[derive(Debug)] -pub enum PluginError { - /// A recoverable error (e.g. invalid params, resource busy) - Recoverable(String), - /// A fatal error (plugin should be disabled) - Fatal(String), - /// Action not understood by this plugin - UnknownAction(String), -} - -impl std::fmt::Display for PluginError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Recoverable(msg) => write!(f, "{msg}"), - Self::Fatal(msg) => write!(f, "FATAL: {msg}"), - Self::UnknownAction(action) => write!(f, "unknown action: {action}"), - } - } -} - -impl std::error::Error for PluginError {} - -type RenderFn = - std::sync::Arc; - -/// A widget that a plugin registers for rendering in the TUI. -pub struct WidgetRegistration { - pub name: String, - pub render: RenderFn, -} - -impl Debug for WidgetRegistration { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("WidgetRegistration") - .field("name", &self.name) - .finish() - } -} - -/// Context passed to plugin lifecycle methods. -/// Provides safe, limited access to application state and plugin-specific directories. -pub struct PluginContext<'a> { - pub(crate) state: &'a mut AppState, - pub(crate) plugin_data_dir: std::path::PathBuf, - pub(crate) capabilities: Vec, -} - -impl PluginContext<'_> { - fn check_capability(&self, cap: &PluginCapability) -> Result<(), PluginError> { - if self.capabilities.contains(cap) { - Ok(()) - } else { - Err(PluginError::Recoverable(format!( - "plugin does not have required capability: {:?}", - cap - ))) - } - } - - /// Full system snapshot with all available metrics. - /// Requires `ReadSystemInfo` capability. - pub fn snapshot(&self) -> SystemSnapshot { - self.state.snapshot() - } - - /// The top N processes sorted by CPU usage. - /// Requires `ReadSystemInfo` capability. - pub fn top_processes(&self, n: usize) -> Vec { - let snap = self.snapshot(); - snap.processes.into_iter().take(n).collect() - } - - /// Kill a process by PID. Returns true if the signal was sent. - /// Requires `KillProcesses` capability. - pub fn kill_process(&mut self, pid: u32) -> Result { - self.check_capability(&PluginCapability::KillProcesses)?; - Ok(self.state.kill_process_by_pid(pid)) - } - - /// Set alert thresholds for CPU, memory, and disk. - /// Requires `ModifyConfig` capability. - pub fn set_alert_thresholds( - &mut self, - cpu: f64, - mem: f64, - disk: f64, - ) -> Result<(), PluginError> { - self.check_capability(&PluginCapability::ModifyConfig)?; - self.state.set_alert_thresholds(cpu, mem, disk); - Ok(()) - } - - /// Switch to a theme by name. Returns true if found. - /// Requires `ModifyConfig` capability. - pub fn set_theme_by_name(&mut self, name: &str) -> Result { - self.check_capability(&PluginCapability::ModifyConfig)?; - Ok(self.state.set_theme_by_name(name)) - } - - /// Switch to a layout by name. Returns true if found. - /// Requires `ModifyConfig` capability. - pub fn set_layout_by_name(&mut self, name: &str) -> Result { - self.check_capability(&PluginCapability::ModifyConfig)?; - Ok(self.state.set_layout_by_name(name)) - } - - /// Set the update interval in milliseconds. - /// Requires `ModifyConfig` capability. - pub fn set_update_interval(&mut self, ms: u64) -> Result<(), PluginError> { - self.check_capability(&PluginCapability::ModifyConfig)?; - self.state.update_interval_ms = ms; - Ok(()) - } - - /// Current system info (hostname, OS, kernel). - /// Requires `ReadSystemInfo` capability. - pub fn system_info(&self) -> crate::domain::metrics::SystemInfo { - self.state.sys_info.clone() - } - - /// Plugin-specific data directory (`~/.config/xtop/plugins//`). - pub fn data_dir(&self) -> &std::path::Path { - &self.plugin_data_dir - } - - /// Current AppState read-only snapshot for widget rendering data. - /// Requires `ReadSystemInfo` capability. - pub fn state(&self) -> &AppState { - self.state - } -} - -/// The core trait that every plugin must implement. -/// -/// All methods have default empty implementations so plugins only -/// override what they need. -pub trait Plugin: Debug + Send { - /// Static metadata about this plugin. - fn manifest(&self) -> PluginManifest; - - /// Called once when the plugin is loaded and enabled. - fn on_enable(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> { - Ok(()) - } - - /// Called once when the plugin is disabled or xtop shuts down. - fn on_disable(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> { - Ok(()) - } - - /// Called on every tick (every ~1s by default). - fn on_tick(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> { - Ok(()) - } - - /// Called when a key is pressed. - /// Return `Ok(true)` if the plugin consumed the key event. - fn on_key(&mut self, _ctx: &mut PluginContext, _key: &str) -> Result { - Ok(false) - } - - /// Optionally provide additional system data. - /// The returned provider is merged into the main data stream via CompositeProvider. - fn data_provider(&self) -> Option> { - None - } - - /// Optionally register a custom widget for TUI rendering. - fn widget(&self) -> Option { - None - } - - /// Execute a named command with string parameters. - /// Used by external agents (AI, CLI, IPC) to interact with the plugin. - /// - /// Returns a JSON-like string response. - fn execute( - &mut self, - _ctx: &mut PluginContext, - _action: &str, - _params: &str, - ) -> Result { - Err(PluginError::UnknownAction(_action.to_string())) - } -} diff --git a/crates/xtop-core/src/domain/system_info.rs b/crates/xtop-core/src/domain/system_info.rs deleted file mode 100644 index 6f03984..0000000 --- a/crates/xtop-core/src/domain/system_info.rs +++ /dev/null @@ -1,32 +0,0 @@ -use crate::domain::metrics::*; - -pub trait SystemDataProvider: Send { - fn refresh_all(&mut self); - fn snapshot(&self) -> SystemSnapshot; - fn disk_io(&self) -> Vec { - vec![] - } - fn batteries(&self) -> Vec { - vec![] - } - fn gpu_info(&self) -> Vec { - vec![] - } - fn docker_info(&self) -> Vec { - vec![] - } - fn system_info(&self) -> SystemInfo { - SystemInfo::default() - } - fn kill_process(&self, _pid: u32) -> bool { - false - } - - /// Downcast to `Any` for internal provider composition. - fn as_any(&self) -> &dyn std::any::Any; - fn as_any_mut(&mut self) -> &mut dyn std::any::Any; - - /// Add extra data providers (used by CompositeProvider). - /// Default no-op implementation for non-composite providers. - fn add_extras(&mut self, _extras: Vec>) {} -} diff --git a/crates/xtop-core/src/infrastructure/layout_loader.rs b/crates/xtop-core/src/infrastructure/layout_loader.rs deleted file mode 100644 index e551bcb..0000000 --- a/crates/xtop-core/src/infrastructure/layout_loader.rs +++ /dev/null @@ -1,273 +0,0 @@ -use crate::domain::layout::{Direction, LayoutArea, LayoutConstraint, LayoutDef, LayoutNode}; -use std::fs; -use std::path::Path; - -fn strip_jsonc_comments(input: &str) -> String { - let mut out = String::with_capacity(input.len()); - let chars: Vec = input.chars().collect(); - let mut i = 0; - while i < chars.len() { - if chars[i] == '/' && i + 1 < chars.len() { - if chars[i + 1] == '/' { - i += 2; - while i < chars.len() && chars[i] != '\n' { - i += 1; - } - continue; - } - if chars[i + 1] == '*' { - i += 2; - while i + 1 < chars.len() && !(chars[i] == '*' && chars[i + 1] == '/') { - i += 1; - } - i += 2; - continue; - } - } - out.push(chars[i]); - i += 1; - } - out -} - -fn load_layout_from_file(path: &Path) -> Option { - let data = fs::read_to_string(path).ok()?; - let cleaned = strip_jsonc_comments(&data); - serde_json::from_str::(&cleaned).ok() -} - -pub fn layouts_dir() -> std::path::PathBuf { - if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { - std::path::PathBuf::from(xdg).join("xtop").join("layouts") - } else if let Ok(home) = std::env::var("HOME") { - std::path::PathBuf::from(home) - .join(".config") - .join("xtop") - .join("layouts") - } else { - std::path::PathBuf::from(".") - .join(".config") - .join("xtop") - .join("layouts") - } -} - -pub fn load_custom_layouts() -> Vec { - let dir = layouts_dir(); - if !dir.exists() { - return vec![]; - } - let mut layouts = vec![]; - if let Ok(entries) = fs::read_dir(&dir) { - for entry in entries.flatten() { - let path = entry.path(); - let ext = path.extension().and_then(|e| e.to_str()); - if ext == Some("json") || ext == Some("jsonc") { - if let Some(layout) = load_layout_from_file(&path) { - layouts.push(layout); - } - } - } - } - layouts -} - -pub fn builtin_layouts() -> Vec { - vec![ - dashboard_layout(), - vertical_layout(), - horizontal_layout(), - cpu_focus_layout(), - memory_focus_layout(), - network_focus_layout(), - process_focus_layout(), - ] -} - -fn area(size: u16, node: LayoutNode) -> LayoutArea { - LayoutArea { - constraint: LayoutConstraint::Length(size), - node, - } -} - -fn pct(pct: u16, node: LayoutNode) -> LayoutArea { - LayoutArea { - constraint: LayoutConstraint::Percentage(pct), - node, - } -} - -fn fill(node: LayoutNode) -> LayoutArea { - LayoutArea { - constraint: LayoutConstraint::Fill, - node, - } -} - -fn widget(name: &str) -> LayoutNode { - LayoutNode::Widget { - name: name.to_string(), - } -} - -fn split_h(areas: Vec) -> LayoutNode { - LayoutNode::Split { - direction: Direction::Horizontal, - areas, - } -} - -fn split_v(areas: Vec) -> LayoutNode { - LayoutNode::Split { - direction: Direction::Vertical, - areas, - } -} - -fn dashboard_layout() -> LayoutDef { - LayoutDef { - name: "Dashboard".into(), - root: split_v(vec![ - area(3, widget("header")), - pct( - 45, - split_h(vec![ - pct(50, widget("cpu")), - pct( - 50, - split_v(vec![ - pct(33, widget("memory")), - pct(33, widget("storage")), - pct(34, widget("network")), - ]), - ), - ]), - ), - pct(52, widget("processes")), - ]), - } -} - -fn vertical_layout() -> LayoutDef { - LayoutDef { - name: "Vertical".into(), - root: split_v(vec![ - area(3, widget("header")), - area(8, widget("cpu")), - area(8, widget("memory")), - area(6, widget("storage")), - area(5, widget("network")), - fill(widget("processes")), - ]), - } -} - -fn horizontal_layout() -> LayoutDef { - LayoutDef { - name: "Horizontal".into(), - root: split_v(vec![ - area(3, widget("header")), - fill(split_h(vec![ - pct(25, widget("cpu")), - pct(25, widget("memory")), - pct(25, widget("storage")), - pct(25, widget("network")), - ])), - ]), - } -} - -fn cpu_focus_layout() -> LayoutDef { - LayoutDef { - name: "CPU Focus".into(), - root: split_v(vec![ - area(3, widget("header")), - pct(60, widget("cpu")), - fill(widget("processes")), - ]), - } -} - -fn memory_focus_layout() -> LayoutDef { - LayoutDef { - name: "Memory Focus".into(), - root: split_v(vec![ - area(3, widget("header")), - pct(60, widget("memory")), - fill(widget("processes")), - ]), - } -} - -fn network_focus_layout() -> LayoutDef { - LayoutDef { - name: "Network Focus".into(), - root: split_v(vec![ - area(3, widget("header")), - pct( - 50, - split_h(vec![pct(50, widget("network")), pct(50, widget("disk_io"))]), - ), - fill(widget("processes")), - ]), - } -} - -fn process_focus_layout() -> LayoutDef { - LayoutDef { - name: "Process Focus".into(), - root: split_v(vec![ - area(3, widget("header")), - area( - 8, - split_h(vec![ - pct(25, widget("cpu")), - pct(25, widget("memory")), - pct(25, widget("storage")), - pct(25, widget("network")), - ]), - ), - fill(widget("processes")), - ]), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_builtin_layouts_count() { - let layouts = builtin_layouts(); - assert_eq!(layouts.len(), 7); - } - - #[test] - fn test_builtin_names() { - let layouts = builtin_layouts(); - let names: Vec<&str> = layouts.iter().map(|l| l.name.as_str()).collect(); - assert!(names.contains(&"Dashboard")); - assert!(names.contains(&"Vertical")); - assert!(names.contains(&"CPU Focus")); - assert!(names.contains(&"Process Focus")); - } - - #[test] - fn test_load_layout_from_jsonc() { - let jsonc = r#"{ - // my custom layout - "name": "test", - "root": { - "direction": "vertical", - "areas": [ - { "widget": "header", "size": 3 }, - { "widget": "cpu", "size": "*" } - ] - } - }"#; - let cleaned = strip_jsonc_comments(jsonc); - let layout: LayoutDef = serde_json::from_str(&cleaned).unwrap(); - assert_eq!(layout.name, "test"); - } -} diff --git a/crates/xtop-core/src/infrastructure/mod.rs b/crates/xtop-core/src/infrastructure/mod.rs deleted file mode 100644 index d6c12a1..0000000 --- a/crates/xtop-core/src/infrastructure/mod.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub mod composite_provider; -pub mod config; -pub mod layout_loader; -pub mod sysinfo_provider; -pub mod theme_loader; diff --git a/crates/xtop-core/src/lib.rs b/crates/xtop-core/src/lib.rs deleted file mode 100644 index 50d0795..0000000 --- a/crates/xtop-core/src/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -pub mod application; -pub mod domain; -pub mod infrastructure; diff --git a/crates/xtop-tui/Cargo.toml b/crates/xtop-tui/Cargo.toml deleted file mode 100644 index dc9be70..0000000 --- a/crates/xtop-tui/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "xtop-tui" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -xtop-core = { path = "../xtop-core" } -ratatui.workspace = true -crossterm.workspace = true diff --git a/crates/xtop-tui/src/color.rs b/crates/xtop-tui/src/color.rs deleted file mode 100644 index 6cfd77e..0000000 --- a/crates/xtop-tui/src/color.rs +++ /dev/null @@ -1,19 +0,0 @@ -use ratatui::prelude::Color; - -pub fn to_color(c: &[u8; 3]) -> Color { - Color::Rgb(c[0], c[1], c[2]) -} - -/// Returns a palette index for gauge color based on percentage: -/// - <50% → green (2) -/// - 50–79% → yellow (3) -/// - ≥80% → red (1) -pub fn gauge_gradient(pct: f64, alert_at: f64) -> usize { - if pct >= alert_at { - 1 - } else if pct >= 50.0 { - 3 - } else { - 2 - } -} diff --git a/crates/xtop-tui/src/format.rs b/crates/xtop-tui/src/format.rs deleted file mode 100644 index d3036f2..0000000 --- a/crates/xtop-tui/src/format.rs +++ /dev/null @@ -1,81 +0,0 @@ -pub fn format_bytes(bytes: u64) -> String { - const UNITS: &[&str] = &["B", "KB", "MB", "GB", "TB"]; - let mut size = bytes as f64; - let mut unit_idx = 0; - while size >= 1024.0 && unit_idx < UNITS.len() - 1 { - size /= 1024.0; - unit_idx += 1; - } - format!("{:.2} {}", size, UNITS[unit_idx]) -} - -pub fn format_uptime(secs: u64) -> String { - let days = secs / 86400; - let hours = (secs % 86400) / 3600; - let minutes = (secs % 3600) / 60; - let seconds = secs % 60; - format!("{}d {}h {}m {}s", days, hours, minutes, seconds) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_format_bytes_bytes() { - assert_eq!(format_bytes(0), "0.00 B"); - assert_eq!(format_bytes(500), "500.00 B"); - } - - #[test] - fn test_format_bytes_kb() { - assert_eq!(format_bytes(1024), "1.00 KB"); - assert_eq!(format_bytes(2048), "2.00 KB"); - assert_eq!(format_bytes(1536), "1.50 KB"); - } - - #[test] - fn test_format_bytes_mb() { - assert_eq!(format_bytes(1048576), "1.00 MB"); - assert_eq!(format_bytes(3145728), "3.00 MB"); - } - - #[test] - fn test_format_bytes_gb() { - assert_eq!(format_bytes(1073741824), "1.00 GB"); - let two_gb = 2u64 * 1024 * 1024 * 1024; - assert_eq!(format_bytes(two_gb), "2.00 GB"); - } - - #[test] - fn test_format_bytes_tb() { - let one_tb = 1024u64 * 1024 * 1024 * 1024; - assert_eq!(format_bytes(one_tb), "1.00 TB"); - } - - #[test] - fn test_format_uptime_zero() { - assert_eq!(format_uptime(0), "0d 0h 0m 0s"); - } - - #[test] - fn test_format_uptime_full() { - let secs = 1 + 60 * 2 + 3600 * 3 + 86400 * 4; // 4d 3h 2m 1s - assert_eq!(format_uptime(secs), "4d 3h 2m 1s"); - } - - #[test] - fn test_format_uptime_seconds_only() { - assert_eq!(format_uptime(59), "0d 0h 0m 59s"); - } - - #[test] - fn test_format_uptime_exact_hour() { - assert_eq!(format_uptime(3600), "0d 1h 0m 0s"); - } - - #[test] - fn test_format_uptime_exact_day() { - assert_eq!(format_uptime(86400), "1d 0h 0m 0s"); - } -} diff --git a/crates/xtop-tui/src/lib.rs b/crates/xtop-tui/src/lib.rs deleted file mode 100644 index db59d20..0000000 --- a/crates/xtop-tui/src/lib.rs +++ /dev/null @@ -1,4 +0,0 @@ -pub mod color; -pub mod format; -pub mod render; -pub mod terminal; diff --git a/crates/xtop-tui/src/render/battery.rs b/crates/xtop-tui/src/render/battery.rs deleted file mode 100644 index ae4fa51..0000000 --- a/crates/xtop-tui/src/render/battery.rs +++ /dev/null @@ -1,61 +0,0 @@ -use crate::color::to_color; -use ratatui::prelude::*; -use ratatui::symbols::border; -use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap}; -use ratatui::Frame; -use xtop_core::application::state::AppState; - -pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let fg = to_color(state.current_theme.fg()); - let bg = to_color(state.current_theme.bg()); - - let block = Block::default() - .title("Battery") - .borders(Borders::ALL) - .border_set(border::ROUNDED) - .style(Style::default().fg(fg).bg(bg)); - let inner = block.inner(area); - f.render_widget(block, area); - - let snap = state.snapshot(); - if snap.batteries.is_empty() { - let msg = Paragraph::new("No battery data available") - .style(Style::default().fg(fg)) - .wrap(Wrap { trim: true }); - f.render_widget(msg, inner); - return; - } - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(vec![Constraint::Length(3); snap.batteries.len()]) - .split(inner); - - for (i, bat) in snap.batteries.iter().enumerate() { - if i >= chunks.len() { - break; - } - let time_info = match (bat.time_to_full, bat.time_to_empty) { - (Some(t), _) if bat.state == "Charging" => { - format!(" {}m to full", t / 60) - } - (_, Some(t)) if bat.state == "Discharging" => { - format!(" {}m remaining", t / 60) - } - _ => String::new(), - }; - let label = format!( - "{} {:>3.0}% {} {}", - bat.name, bat.percentage, bat.state, time_info, - ); - let gauge = Gauge::default() - .gauge_style( - Style::default() - .fg(to_color(&state.current_theme.palette[2])) - .bg(bg), - ) - .percent(bat.percentage as u16) - .label(label); - f.render_widget(gauge, chunks[i]); - } -} diff --git a/crates/xtop-tui/src/render/cpu.rs b/crates/xtop-tui/src/render/cpu.rs deleted file mode 100644 index 5351b3b..0000000 --- a/crates/xtop-tui/src/render/cpu.rs +++ /dev/null @@ -1,139 +0,0 @@ -use crate::color::{gauge_gradient, to_color}; -use ratatui::prelude::*; -use ratatui::symbols::border; -use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, Gauge, GraphType}; -use ratatui::Frame; -use xtop_core::application::state::AppState; - -pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let fg = to_color(state.current_theme.fg()); - let bg = to_color(state.current_theme.bg()); - let snap = state.snapshot(); - - let title = if snap.cpu_temp > 0.0 { - format!("CPU (Max: {:.1}°C)", snap.cpu_temp) - } else { - "CPU".to_string() - }; - - let block = Block::default() - .title(title) - .borders(Borders::ALL) - .border_set(border::ROUNDED) - .style(Style::default().fg(fg).bg(bg)); - let inner = block.inner(area); - f.render_widget(block, area); - - if snap.cpus.is_empty() { - return; - } - - let count = snap.cpus.len(); - let cols = if inner.width > 40 { 2 } else { 1 }; - let col_constraints = if cols == 2 { - vec![Constraint::Percentage(50); 2] - } else { - vec![Constraint::Percentage(100)] - }; - let col_areas = Layout::default() - .direction(Direction::Horizontal) - .constraints(col_constraints) - .split(inner); - - let per_col = count.div_ceil(cols); - let chart_avail = inner.height > per_col as u16 + 4; - - // Render all core gauges - for (col_idx, col_area) in col_areas.iter().enumerate() { - let start = col_idx * per_col; - let end = (start + per_col).min(count); - - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints(vec![Constraint::Length(1); end - start]) - .split(*col_area); - - for (i, row_area) in rows.iter().enumerate() { - let cpu_idx = start + i; - if cpu_idx >= count { - break; - } - let cpu = &snap.cpus[cpu_idx]; - let usage = cpu.usage; - let color_idx = if usage > state.alerts.cpu_high { - 1 - } else { - gauge_gradient(usage, state.alerts.cpu_high) - }; - let label = format!("CPU{:<2} {:>3.0}%", cpu.cpu_id, usage); - let gauge = Gauge::default() - .gauge_style( - Style::default() - .fg(to_color(&state.current_theme.palette[color_idx])) - .bg(bg), - ) - .percent(usage as u16) - .label(label); - f.render_widget(gauge, *row_area); - } - } - - // Aggregate CPU chart below gauges - if chart_avail { - let gauge_height = per_col as u16; // min height for gauges in one column - let chart_area = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(gauge_height), Constraint::Min(0)]) - .split(inner) - .last() - .copied() - .unwrap_or(inner); - - let max_len = state.history.cpu.iter().map(|h| h.len()).max().unwrap_or(0); - if max_len > 1 { - let mut avg: Vec<(f64, f64)> = Vec::new(); - for tick in 0..max_len { - let mut sum = 0.0; - let mut n = 0; - for core_hist in &state.history.cpu { - if tick < core_hist.len() { - sum += core_hist[tick].1; - n += 1; - } - } - if n > 0 { - let x = state.history.cpu[0] - .get(tick) - .map(|&(x, _)| x) - .unwrap_or(0.0); - avg.push((x, sum / n as f64)); - } - } - - let datasets = vec![Dataset::default() - .name("CPU Avg") - .marker(symbols::Marker::Braille) - .graph_type(GraphType::Line) - .style(Style::default().fg(to_color(&state.current_theme.palette[1]))) - .data(&avg)]; - - let x_min = avg.first().map(|&(x, _)| x).unwrap_or(0.0); - let x_max = avg.last().map(|&(x, _)| x).unwrap_or(100.0); - let x_max = x_max.max(x_min + 1.0); - - let chart = Chart::new(datasets) - .block(Block::default().borders(Borders::TOP)) - .x_axis( - Axis::default() - .bounds([x_min, x_max]) - .labels(vec![Span::raw("")]), - ) - .y_axis(Axis::default().bounds([0.0, 100.0]).labels(vec![ - Span::raw("0%"), - Span::raw("50%"), - Span::raw("100%"), - ])); - f.render_widget(chart, chart_area); - } - } -} diff --git a/crates/xtop-tui/src/render/disk_io.rs b/crates/xtop-tui/src/render/disk_io.rs deleted file mode 100644 index cd0ce1a..0000000 --- a/crates/xtop-tui/src/render/disk_io.rs +++ /dev/null @@ -1,85 +0,0 @@ -use crate::color::to_color; -use crate::format::format_bytes; -use ratatui::prelude::*; -use ratatui::symbols::border; -use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap}; -use ratatui::Frame; -use xtop_core::application::state::AppState; - -pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let fg = to_color(state.current_theme.fg()); - let bg = to_color(state.current_theme.bg()); - - let block = Block::default() - .title("Disk I/O") - .borders(Borders::ALL) - .border_set(border::PLAIN) - .style(Style::default().fg(fg).bg(bg)); - let inner = block.inner(area); - f.render_widget(block, area); - - let snap = state.snapshot(); - if snap.disk_io.is_empty() { - let msg = Paragraph::new("No disk I/O data") - .style(Style::default().fg(fg)) - .wrap(Wrap { trim: true }); - f.render_widget(msg, inner); - return; - } - - // Find max speed for proportional gauge - let max_speed = snap - .disk_io - .iter() - .map(|d| d.read_speed.max(d.write_speed)) - .fold(0.0_f64, f64::max) - .max(1.0); - - let per_disk = 3.min(inner.height / snap.disk_io.len().max(1) as u16); - let per_disk = per_disk.max(2); - let constraints = vec![Constraint::Length(per_disk); snap.disk_io.len()]; - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(constraints) - .split(inner); - - for (i, d) in snap.disk_io.iter().enumerate() { - if i >= chunks.len() { - break; - } - let read_speed = format_bytes(d.read_speed as u64); - let write_speed = format_bytes(d.write_speed as u64); - - let gauge = Gauge::default() - .gauge_style( - Style::default() - .fg(to_color(&state.current_theme.palette[4])) - .bg(bg), - ) - .percent((d.read_speed / max_speed * 100.0) as u16) - .label(format!(" {} R: {}/s", d.name, read_speed)); - f.render_widget(gauge, chunks[i]); - - // Draw write speed as a second line if there's room - if per_disk >= 3 { - let sub = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(1), Constraint::Length(1)]) - .split(chunks[i]); - let write_gauge = Gauge::default() - .gauge_style( - Style::default() - .fg(to_color(&state.current_theme.palette[5])) - .bg(bg), - ) - .percent((d.write_speed / max_speed * 100.0) as u16) - .label(format!( - " W: {}/s Tot R: {} Tot W: {}", - write_speed, - format_bytes(d.read_bytes), - format_bytes(d.write_bytes) - )); - f.render_widget(write_gauge, sub[1]); - } - } -} diff --git a/crates/xtop-tui/src/render/gpu.rs b/crates/xtop-tui/src/render/gpu.rs deleted file mode 100644 index 5aafe69..0000000 --- a/crates/xtop-tui/src/render/gpu.rs +++ /dev/null @@ -1,57 +0,0 @@ -use crate::color::to_color; -use crate::format::format_bytes; -use ratatui::prelude::*; -use ratatui::symbols::border; -use ratatui::widgets::{Block, Borders, Gauge, Paragraph, Wrap}; -use ratatui::Frame; -use xtop_core::application::state::AppState; - -pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let fg = to_color(state.current_theme.fg()); - let bg = to_color(state.current_theme.bg()); - - let block = Block::default() - .title("GPU") - .borders(Borders::ALL) - .border_set(border::ROUNDED) - .style(Style::default().fg(fg).bg(bg)); - let inner = block.inner(area); - f.render_widget(block, area); - - let snap = state.snapshot(); - if snap.gpus.is_empty() { - let msg = Paragraph::new("No GPU data available") - .style(Style::default().fg(fg)) - .wrap(Wrap { trim: true }); - f.render_widget(msg, inner); - return; - } - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(vec![Constraint::Length(3); snap.gpus.len()]) - .split(inner); - - for (i, gpu) in snap.gpus.iter().enumerate() { - if i >= chunks.len() { - break; - } - let label = format!( - "{} {:>3.0}% Mem: {} / {} Temp: {:.1}°C", - gpu.name, - gpu.usage, - format_bytes(gpu.memory_used), - format_bytes(gpu.memory_total), - gpu.temperature, - ); - let gauge = Gauge::default() - .gauge_style( - Style::default() - .fg(to_color(&state.current_theme.palette[5])) - .bg(bg), - ) - .percent(gpu.usage as u16) - .label(label); - f.render_widget(gauge, chunks[i]); - } -} diff --git a/crates/xtop-tui/src/render/header.rs b/crates/xtop-tui/src/render/header.rs deleted file mode 100644 index d3f7496..0000000 --- a/crates/xtop-tui/src/render/header.rs +++ /dev/null @@ -1,71 +0,0 @@ -use crate::color::to_color; -use crate::format::format_uptime; -use ratatui::prelude::*; -use ratatui::symbols::border; -use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; -use ratatui::Frame; -use xtop_core::application::state::{AppState, FullScreenWidget, InputMode}; - -pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let fg = to_color(state.current_theme.fg()); - let bg = to_color(state.current_theme.bg()); - - let snap = state.snapshot(); - let load = snap.load_avg; - let uptime = snap.uptime; - - let mode_str = state.current_layout_name(); - - let mut extras = String::new(); - if state.full_screen_widget != FullScreenWidget::None { - extras.push_str(&format!(" [Full: {}]", state.full_screen_widget.label())); - } - if state.input_mode == InputMode::Searching { - extras.push_str(" [/] Search"); - } - - let host = &state.sys_info.hostname; - - let wide = area.width >= 80; - let text: Vec = if wide { - vec![Line::from(format!( - "{} | {} | {} | Uptime: {} | Load: {:.2} {:.2} {:.2}{}", - if host.is_empty() { - "xtop".to_string() - } else { - host.clone() - }, - state.current_theme.name, - mode_str, - format_uptime(uptime), - load.one, - load.five, - load.fifteen, - extras, - ))] - } else { - let host_part = if host.is_empty() { - mode_str.to_string() - } else { - format!("{} | {}", host, mode_str) - }; - vec![ - Line::from(format!("{} | Uptime: {}", host_part, format_uptime(uptime),)), - Line::from(format!( - "Load: {:.2} {:.2} {:.2}{}", - load.one, load.five, load.fifteen, extras, - )), - ] - }; - - let p = Paragraph::new(text) - .style(Style::default().fg(fg).bg(bg)) - .block( - Block::default() - .borders(Borders::ALL) - .border_set(border::PLAIN) - .title("System Info"), - ) - .wrap(Wrap { trim: true }); - f.render_widget(p, area); -} diff --git a/crates/xtop-tui/src/render/help.rs b/crates/xtop-tui/src/render/help.rs deleted file mode 100644 index aba850e..0000000 --- a/crates/xtop-tui/src/render/help.rs +++ /dev/null @@ -1,54 +0,0 @@ -use crate::color::to_color; -use ratatui::prelude::*; -use ratatui::symbols::border; -use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; -use ratatui::Frame; -use xtop_core::application::state::AppState; - -pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let fg = to_color(state.current_theme.fg()); - let bg = to_color(state.current_theme.bg()); - - let text = vec![ - Line::from(""), - Line::from(vec![Span::styled( - " Keybindings", - Style::default().add_modifier(Modifier::BOLD), - )]), - Line::from(""), - Line::from(" ─────────────────────────────────────────────"), - Line::from(" q Quit application"), - Line::from(" ? Toggle this help screen"), - Line::from(""), - Line::from(" t Next color theme"), - Line::from(" T Previous color theme"), - Line::from(" l Next layout mode"), - Line::from(" f Toggle fullscreen widget"), - Line::from(" F Cycle fullscreen widget"), - Line::from(""), - Line::from(" / Search/filter processes"), - Line::from(" Esc Cancel search / close help"), - Line::from(""), - Line::from(" Layout modes:"), - Line::from(format!(" Current: {}", state.current_layout_name())), - Line::from(" Dashboard | Vertical | Horizontal | CPU Focus"), - Line::from(" Memory Focus | Network Focus | Process Focus"), - Line::from(" + custom layouts from ~/.config/xtop/layouts/"), - Line::from(""), - Line::from(" ─────────────────────────────────────────────"), - Line::from(""), - Line::from(" https://github.com/xscriptor/xtop"), - Line::from(""), - ]; - - let block = Block::default() - .title("Help") - .borders(Borders::ALL) - .border_set(border::DOUBLE) - .style(Style::default().fg(fg).bg(bg)); - let p = Paragraph::new(text) - .block(block) - .style(Style::default().fg(fg).bg(bg)) - .wrap(Wrap { trim: false }); - f.render_widget(p, area); -} diff --git a/crates/xtop-tui/src/render/layout_engine.rs b/crates/xtop-tui/src/render/layout_engine.rs deleted file mode 100644 index 1113bb0..0000000 --- a/crates/xtop-tui/src/render/layout_engine.rs +++ /dev/null @@ -1,88 +0,0 @@ -use crate::render::{battery, cpu, disk_io, gpu, header, memory, network, processes, storage}; -use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::Frame; -use std::collections::HashMap; -use std::sync::Arc; -use xtop_core::application::state::AppState; -use xtop_core::domain::layout::{Direction, LayoutArea, LayoutDef, LayoutNode}; - -/// A widget renderer: a callable that draws a widget onto the terminal. -pub type WidgetFn = Arc; - -/// Create the default built-in widget map. -pub fn default_widgets() -> HashMap<&'static str, WidgetFn> { - let mut m: HashMap<&'static str, WidgetFn> = HashMap::new(); - m.insert("header", Arc::new(header::render)); - m.insert("cpu", Arc::new(cpu::render)); - m.insert("memory", Arc::new(memory::render)); - m.insert("storage", Arc::new(storage::render)); - m.insert("network", Arc::new(network::render)); - m.insert("processes", Arc::new(processes::render)); - m.insert("disk_io", Arc::new(disk_io::render)); - m.insert("battery", Arc::new(battery::render)); - m.insert("gpu", Arc::new(gpu::render)); - m -} - -/// Render a layout definition within a given area. -/// -/// `widgets` is the built-in registry. `plugin_widgets` is an optional -/// extension from plugins. Plugin widgets take precedence over built-ins. -pub fn render_layout( - f: &mut Frame, - state: &AppState, - area: Rect, - def: &LayoutDef, - widgets: &HashMap<&'static str, WidgetFn>, - plugin_widgets: &HashMap, -) { - render_node(f, state, area, &def.root, widgets, plugin_widgets); -} - -fn render_node( - f: &mut Frame, - state: &AppState, - area: Rect, - node: &LayoutNode, - widgets: &HashMap<&'static str, WidgetFn>, - plugin_widgets: &HashMap, -) { - match node { - LayoutNode::Widget { name } => { - // Plugin widgets take precedence - if let Some(render_fn) = plugin_widgets.get(name) { - render_fn(f, state, area); - } else if let Some(render_fn) = widgets.get(name.as_str()) { - render_fn(f, state, area); - } - // Unknown widgets are silently ignored (backward-compatible) - } - LayoutNode::Split { direction, areas } => { - if areas.is_empty() { - return; - } - let dir = match direction { - Direction::Horizontal => ratatui::prelude::Direction::Horizontal, - Direction::Vertical => ratatui::prelude::Direction::Vertical, - }; - let constraints: Vec = areas.iter().map(to_ratatui_constraint).collect(); - let chunks = Layout::default() - .direction(dir) - .constraints(constraints) - .split(area); - for (i, chunk) in chunks.iter().enumerate() { - if i < areas.len() { - render_node(f, state, *chunk, &areas[i].node, widgets, plugin_widgets); - } - } - } - } -} - -fn to_ratatui_constraint(area: &LayoutArea) -> Constraint { - match area.constraint { - xtop_core::domain::layout::LayoutConstraint::Length(n) => Constraint::Length(n), - xtop_core::domain::layout::LayoutConstraint::Percentage(p) => Constraint::Percentage(p), - xtop_core::domain::layout::LayoutConstraint::Fill => Constraint::Fill(1), - } -} diff --git a/crates/xtop-tui/src/render/memory.rs b/crates/xtop-tui/src/render/memory.rs deleted file mode 100644 index 3480083..0000000 --- a/crates/xtop-tui/src/render/memory.rs +++ /dev/null @@ -1,140 +0,0 @@ -use crate::color::{gauge_gradient, to_color}; -use crate::format::format_bytes; -use ratatui::prelude::*; -use ratatui::symbols::border; -use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, Gauge, GraphType}; -use ratatui::Frame; -use xtop_core::application::state::AppState; - -pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let fg = to_color(state.current_theme.fg()); - let bg = to_color(state.current_theme.bg()); - let snap = state.snapshot(); - - let mem_alert = snap.memory.percent > state.alerts.mem_high; - let mem_color_idx = if mem_alert { - 1 - } else { - gauge_gradient(snap.memory.percent, state.alerts.mem_high) - }; - - let mut title = "Memory".to_string(); - if mem_alert { - title = format!("Memory ⚠ {:.0}%", snap.memory.percent); - } - - let block = Block::default() - .title(title) - .borders(Borders::ALL) - .border_set(border::ROUNDED) - .style(Style::default().fg(fg).bg(bg)); - let inner = block.inner(area); - f.render_widget(block, area); - - let has_chart_area = inner.height > 7; - if has_chart_area { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Length(3), - Constraint::Min(0), - ]) - .split(inner); - - render_ram_gauge(f, state, chunks[0], &snap, bg, mem_color_idx); - render_swap_gauge(f, state, chunks[1], &snap, bg); - render_chart(f, state, chunks[2], bg); - } else { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Length(3)]) - .split(inner); - render_ram_gauge(f, state, chunks[0], &snap, bg, mem_color_idx); - render_swap_gauge(f, state, chunks[1], &snap, bg); - } -} - -fn render_ram_gauge( - f: &mut Frame, - state: &AppState, - area: Rect, - snap: &xtop_core::domain::metrics::SystemSnapshot, - bg: Color, - color_idx: usize, -) { - let mem_pct = snap.memory.percent as u16; - let label = format!( - "RAM: {} / {} ({:>3.0}%)", - format_bytes(snap.memory.used), - format_bytes(snap.memory.total), - snap.memory.percent, - ); - let gauge = Gauge::default() - .gauge_style( - Style::default() - .fg(to_color(&state.current_theme.palette[color_idx])) - .bg(bg), - ) - .percent(mem_pct) - .label(label); - f.render_widget(gauge, area); -} - -fn render_swap_gauge( - f: &mut Frame, - state: &AppState, - area: Rect, - snap: &xtop_core::domain::metrics::SystemSnapshot, - bg: Color, -) { - let swap_pct = snap.swap.percent as u16; - let color_idx = gauge_gradient(snap.swap.percent, state.alerts.mem_high); - let label = format!( - "SWP: {} / {} ({:>3.0}%)", - format_bytes(snap.swap.used), - format_bytes(snap.swap.total), - snap.swap.percent, - ); - let gauge = Gauge::default() - .gauge_style( - Style::default() - .fg(to_color(&state.current_theme.palette[color_idx])) - .bg(bg), - ) - .percent(swap_pct) - .label(label); - f.render_widget(gauge, area); -} - -fn render_chart(f: &mut Frame, state: &AppState, area: Rect, _bg: Color) { - let mem_data: Vec<(f64, f64)> = state.history.mem.iter().copied().collect(); - if mem_data.is_empty() { - return; - } - - let datasets = vec![Dataset::default() - .name("RAM Usage") - .marker(symbols::Marker::Braille) - .graph_type(GraphType::Line) - .style(Style::default().fg(to_color(&state.current_theme.palette[2]))) - .data(&mem_data)]; - - let x_min = mem_data.first().map(|&(x, _)| x).unwrap_or(0.0); - let x_max = mem_data.last().map(|&(x, _)| x).unwrap_or(100.0); - let x_max = x_max.max(x_min + 1.0); - - let chart = Chart::new(datasets) - .block(Block::default().borders(Borders::TOP)) - .x_axis( - Axis::default() - .bounds([x_min, x_max]) - .labels(vec![Span::raw("")]), - ) - .y_axis(Axis::default().bounds([0.0, 100.0]).labels(vec![ - Span::raw("0%"), - Span::raw("50%"), - Span::raw("100%"), - ])); - f.render_widget(chart, area); -} diff --git a/crates/xtop-tui/src/render/network.rs b/crates/xtop-tui/src/render/network.rs deleted file mode 100644 index aa1639a..0000000 --- a/crates/xtop-tui/src/render/network.rs +++ /dev/null @@ -1,166 +0,0 @@ -use crate::color::to_color; -use crate::format::format_bytes; -use ratatui::prelude::*; -use ratatui::symbols::border; -use ratatui::widgets::{Axis, Block, Borders, Chart, Dataset, GraphType, Paragraph, Wrap}; -use ratatui::Frame; -use xtop_core::application::state::AppState; - -pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let fg = to_color(state.current_theme.fg()); - let bg = to_color(state.current_theme.bg()); - - let block = Block::default() - .title("Network") - .borders(Borders::ALL) - .border_set(border::DOUBLE) - .style(Style::default().fg(fg).bg(bg)); - let inner = block.inner(area); - f.render_widget(block, area); - - let snap = state.snapshot(); - let total_rx: u64 = snap.networks.iter().map(|n| n.received).sum(); - let total_tx: u64 = snap.networks.iter().map(|n| n.transmitted).sum(); - let total_rx_speed: f64 = snap.networks.iter().map(|n| n.rx_speed).sum(); - let total_tx_speed: f64 = snap.networks.iter().map(|n| n.tx_speed).sum(); - - let has_chart = inner.height > 6; - - if has_chart { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(4), Constraint::Min(0)]) - .split(inner); - - render_stats( - f, - state, - chunks[0], - fg, - total_rx, - total_tx, - total_rx_speed, - total_tx_speed, - &snap.networks, - ); - render_net_chart(f, state, chunks[1], bg); - } else { - render_stats( - f, - state, - inner, - fg, - total_rx, - total_tx, - total_rx_speed, - total_tx_speed, - &snap.networks, - ); - } -} - -#[allow(clippy::too_many_arguments)] -fn render_stats( - f: &mut Frame, - state: &AppState, - area: Rect, - fg: Color, - total_rx: u64, - total_tx: u64, - total_rx_speed: f64, - total_tx_speed: f64, - interfaces: &[xtop_core::domain::metrics::NetworkInfo], -) { - let mut text = vec![ - Line::from(vec![ - Span::styled("RX: ", Style::default().fg(fg)), - Span::styled( - format_bytes(total_rx), - Style::default().fg(to_color(&state.current_theme.palette[4])), - ), - Span::raw(" "), - Span::styled( - format!("{}/s", format_bytes(total_rx_speed as u64)), - Style::default().fg(to_color(&state.current_theme.palette[4])), - ), - ]), - Line::from(vec![ - Span::styled("TX: ", Style::default().fg(fg)), - Span::styled( - format_bytes(total_tx), - Style::default().fg(to_color(&state.current_theme.palette[5])), - ), - Span::raw(" "), - Span::styled( - format!("{}/s", format_bytes(total_tx_speed as u64)), - Style::default().fg(to_color(&state.current_theme.palette[5])), - ), - ]), - ]; - - if area.height > 4 { - for iface in interfaces { - if text.len() as u16 >= area.height.saturating_sub(1) { - break; - } - text.push(Line::from(Span::raw(format!( - " {} RX: {} TX: {}", - iface.name, - format_bytes(iface.received), - format_bytes(iface.transmitted), - )))); - } - } - - let p = Paragraph::new(text).wrap(Wrap { trim: true }); - f.render_widget(p, area); -} - -fn render_net_chart(f: &mut Frame, state: &AppState, area: Rect, _bg: Color) { - let rx_data: Vec<(f64, f64)> = state.history.net_rx.iter().copied().collect(); - let tx_data: Vec<(f64, f64)> = state.history.net_tx.iter().copied().collect(); - if rx_data.len() < 2 || tx_data.len() < 2 { - return; - } - - // Find max value for y-axis bounds - let max_val = rx_data - .iter() - .chain(tx_data.iter()) - .map(|&(_, v)| v) - .fold(0.0_f64, f64::max) - .max(1.0); - - let datasets = vec![ - Dataset::default() - .name("RX") - .marker(symbols::Marker::Braille) - .graph_type(GraphType::Line) - .style(Style::default().fg(to_color(&state.current_theme.palette[4]))) - .data(&rx_data), - Dataset::default() - .name("TX") - .marker(symbols::Marker::Braille) - .graph_type(GraphType::Line) - .style(Style::default().fg(to_color(&state.current_theme.palette[5]))) - .data(&tx_data), - ]; - - let x_min = rx_data.first().map(|&(x, _)| x).unwrap_or(0.0); - let x_max = rx_data.last().map(|&(x, _)| x).unwrap_or(100.0); - let x_max = x_max.max(x_min + 1.0); - - let chart = Chart::new(datasets) - .block(Block::default().borders(Borders::TOP)) - .x_axis( - Axis::default() - .bounds([x_min, x_max]) - .labels(vec![Span::raw("")]), - ) - .y_axis(Axis::default().bounds([0.0, max_val]).labels(vec![ - Span::raw("0"), - Span::raw(format!("{:.0}", max_val / 2.0)), - Span::raw(format!("{:.0}", max_val)), - ])); - f.render_widget(chart, area); -} diff --git a/crates/xtop-tui/src/render/processes.rs b/crates/xtop-tui/src/render/processes.rs deleted file mode 100644 index 565c169..0000000 --- a/crates/xtop-tui/src/render/processes.rs +++ /dev/null @@ -1,111 +0,0 @@ -use crate::color::to_color; -use ratatui::prelude::*; -use ratatui::symbols::border; -use ratatui::widgets::{Block, Borders, Cell, Row, Table}; -use ratatui::Frame; -use xtop_core::application::state::AppState; -use xtop_core::domain::metrics::ProcessInfo; - -pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let fg = to_color(state.current_theme.fg()); - let bg = to_color(state.current_theme.bg()); - let dim_bg = to_color(&state.current_theme.palette[8]); - let accent = to_color(&state.current_theme.palette[6]); - - let mut title = format!("Processes (sort: {})", state.process_sort.label()); - if !state.search_query.is_empty() { - title = format!("Processes (filter: {})", state.search_query); - } - - let block = Block::default() - .title(title) - .borders(Borders::ALL) - .border_set(border::PLAIN) - .style(Style::default().fg(fg).bg(bg)); - let inner = block.inner(area); - f.render_widget(block, area); - - let snap = state.snapshot(); - - let iter: Box> = if state.search_query.is_empty() { - Box::new(snap.processes.iter()) - } else { - let q = state.search_query.to_lowercase(); - Box::new( - snap.processes - .iter() - .filter(move |p| p.name.to_lowercase().contains(&q)), - ) - }; - - let mut items: Vec<&ProcessInfo> = iter.collect(); - - // Sort - match state.process_sort { - xtop_core::application::state::ProcessSortBy::Cpu => { - items.sort_by(|a, b| { - b.cpu_usage - .partial_cmp(&a.cpu_usage) - .unwrap_or(std::cmp::Ordering::Equal) - }); - } - xtop_core::application::state::ProcessSortBy::Memory => { - items.sort_by_key(|b| std::cmp::Reverse(b.memory)); - } - xtop_core::application::state::ProcessSortBy::Pid => { - items.sort_by_key(|a| a.pid); - } - xtop_core::application::state::ProcessSortBy::Name => { - items.sort_by_key(|a| a.name.to_lowercase()); - } - } - - let rows: Vec = items - .into_iter() - .enumerate() - .map(|(row_idx, p)| { - let is_selected = state.process_selected == Some(row_idx); - let style = if is_selected { - Style::default() - .fg(bg) - .bg(accent) - .add_modifier(Modifier::BOLD) - } else if row_idx % 2 == 0 { - Style::default().fg(fg) - } else { - Style::default().fg(fg).bg(dim_bg) - }; - Row::new(vec![ - Cell::from(p.pid.to_string()), - Cell::from(p.name.clone()), - Cell::from(format!("{:.1}%", p.cpu_usage)), - Cell::from(crate::format::format_bytes(p.memory)), - Cell::from(p.user_id.clone().unwrap_or_else(|| "?".to_string())), - ]) - .style(style) - }) - .collect(); - - let widths = [ - Constraint::Length(10), - Constraint::Percentage(40), - Constraint::Length(12), - Constraint::Length(17), - Constraint::Length(10), - ]; - - let table = Table::new(rows, widths) - .header( - Row::new(vec!["PID", "Name", "CPU%", "Mem", "User"]) - .style(Style::default().fg(accent).add_modifier(Modifier::BOLD)) - .bottom_margin(1), - ) - .row_highlight_style( - Style::default() - .fg(bg) - .bg(accent) - .add_modifier(Modifier::BOLD), - ); - - f.render_widget(table, inner); -} diff --git a/crates/xtop-tui/src/render/storage.rs b/crates/xtop-tui/src/render/storage.rs deleted file mode 100644 index 182a7ac..0000000 --- a/crates/xtop-tui/src/render/storage.rs +++ /dev/null @@ -1,58 +0,0 @@ -use crate::color::{gauge_gradient, to_color}; -use crate::format::format_bytes; -use ratatui::prelude::*; -use ratatui::symbols::border; -use ratatui::widgets::{Block, Borders, Gauge}; -use ratatui::Frame; -use xtop_core::application::state::AppState; - -pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let fg = to_color(state.current_theme.fg()); - let bg = to_color(state.current_theme.bg()); - - let block = Block::default() - .title("Storage") - .borders(Borders::ALL) - .border_set(border::DOUBLE) - .style(Style::default().fg(fg).bg(bg)); - let inner = block.inner(area); - f.render_widget(block, area); - - let snap = state.snapshot(); - let disks = &snap.disks; - if disks.is_empty() { - return; - } - - let per_disk = inner.height.min(3); - let constraints = vec![Constraint::Length(per_disk); disks.len()]; - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints(constraints) - .split(inner); - - for (i, disk) in disks.iter().enumerate() { - if i >= chunks.len() { - break; - } - let color_idx = gauge_gradient(disk.percent, state.alerts.disk_high); - let fs_type = &disk.file_system; - let label = format!( - "{} [{}] Tot: {} Use: {} Free: {}", - disk.mount_point, - if fs_type.is_empty() { "?" } else { fs_type }, - format_bytes(disk.total_space), - format_bytes(disk.used_space), - format_bytes(disk.available_space), - ); - let gauge = Gauge::default() - .gauge_style( - Style::default() - .fg(to_color(&state.current_theme.palette[color_idx])) - .bg(bg), - ) - .percent(disk.percent as u16) - .label(label); - f.render_widget(gauge, chunks[i]); - } -} diff --git a/colors.md b/docs/colors.md similarity index 100% rename from colors.md rename to docs/colors.md diff --git a/docs/configuration.md b/docs/configuration.md index 261bf2f..51290aa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1,58 +1,139 @@

    Configuration

    -
    - -

    Config File

    +

    xtop persists its configuration automatically on quit. The file is +config.json in the platform config directory, next to the +themes/ and layouts/ folders:

    -

    xtop automatically saves its configuration on quit. The configuration file is located at:

    +
    ~/.config/xtop/config.json                (Linux)
    +~/Library/Application Support/xtop/       (macOS)
    +%APPDATA%\xtop\                           (Windows)
    +
    -
    ~/.config/xtop/config.json
    +

    On Linux you can override the base directory with +$XDG_CONFIG_HOME.


    -

    Persisted Settings

    +

    Keys

    - + + + - + + + + + + + + + - - + + + + - - + + + + - + + + + + + + + + + + + + + + + + + + + + - - + + + +
    SettingKeyTypeDefault Description
    themeCurrently selected color theme namestring"x"Currently selected color theme name (one of the 12 shipped or a user theme).
    layout_modestring"Dashboard"Built-in layout mode. One of Dashboard, Vertical, Horizontal, CpuFocus, MemoryFocus, NetworkFocus, ProcessFocus. Ignored while layout_name names a valid custom layout.
    layoutCurrently selected layout modelayout_namestring""Name of the active layout when it is a custom (non-built-in) layout. When non-empty and found, it takes precedence over layout_mode.
    intervalUpdate interval in millisecondsupdate_interval_msinteger1000Sampling interval in milliseconds. Clamped to 100–3,600,000 on load.
    history_pointsNumber of data points retained for the RAM history chartinteger100Data points retained for the historical charts.
    alertsobjectsee belowAlert thresholds; see Alert Thresholds.
    keybindingsobjectsee belowKey bindings per action; see Keybindings.
    styleobjectsee belowWidget glyph style (chart charset, borders, packs); see Style.
    alert_thresholdsThreshold values for CPU, memory, and disk alertseffectstring (optional)absentFrame effect applied to every rendered frame: "fade" activates the built-in fade-in (only in builds compiled with the effects feature). Any other value disables effects.
    +

    Example

    + +
    {
    +  "theme": "miami",
    +  "layout_mode": "Dashboard",
    +  "layout_name": "",
    +  "update_interval_ms": 1000,
    +  "history_points": 100,
    +  "alerts": {
    +    "cpu_high": 90.0,
    +    "mem_high": 90.0,
    +    "disk_high": 90.0
    +  },
    +  "keybindings": {
    +    "quit": ["q"],
    +    "help": ["?"],
    +    "next_theme": ["t"],
    +    "prev_theme": ["T"],
    +    "next_layout": ["l"],
    +    "toggle_fullscreen": ["f"],
    +    "cycle_fullscreen": ["F"],
    +    "search": ["/"],
    +    "command_palette": ["ctrl+p", "ctrl+P"],
    +    "cancel": ["escape"],
    +    "kill_process": ["k"],
    +    "process_up": ["up"],
    +    "process_down": ["down"],
    +    "cycle_sort": ["s"]
    +  },
    +  "style": {
    +    "charset": "braille",
    +    "borders": "native",
    +    "pack": null,
    +    "widgets": {}
    +  }
    +}
    + +

    Unknown keys and missing optional keys are ignored; a file that cannot be +parsed falls back to the defaults above.

    +

    Alert Thresholds

    -

    When a metric exceeds its configured threshold, the corresponding widget changes color to red and displays a warning indicator in its title.

    +

    When a metric exceeds its configured threshold, the corresponding widget +changes color to red and displays a warning indicator in its title.

    - + @@ -61,23 +142,79 @@ - + - + - +
    ThresholdKey Description Default
    cpu_high CPU usage percentage that triggers a warning90%90.0
    mem_high Memory usage percentage that triggers a warning90%90.0
    disk_high Disk usage percentage that triggers a warning90%90.0

    +

    Keybindings

    + +

    Every action accepts a list of key strings; the first matching key wins. +Keys are written as: single characters ("q", "/", "?"), +shifted characters ("T", "F"), modified keys +("ctrl+p", "alt+x") and named keys +("escape", "enter", "backspace", "tab", +"up", "down", "left", "right", +"delete", "home", "end", "pageup", +"pagedown").

    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Key (action)Default bindingAction
    quit["q"]Save config and quit
    help["?"]Toggle the help overlay
    next_theme["t"]Next theme
    prev_theme["T"]Previous theme
    next_layout["l"]Next layout
    toggle_fullscreen["f"]Toggle full-screen view
    cycle_fullscreen["F"]Cycle the full-screen widget
    search["/"]Start process search
    command_palette["ctrl+p", "ctrl+P"]Open the command palette (ctrl+p also works as a hardcoded fallback)
    cancel["escape"]Cancel search / close overlays
    kill_process["k"]Kill the selected process (same-user safety check)
    process_up["up"]Move the process selection up
    process_down["down"]Move the process selection down
    cycle_sort["s"]Cycle the process sort column (CPU% → Memory → PID → Name)
    + +
    + +

    Style

    + +

    The style object controls widget glyphs. Values are the +ecosystem-wide enums from xtop-widget-api:

    + +
      +
    • charset: braille (default), dot, block, half_block, bar — chart markers used by history charts.
    • +
    • borders: native (default, classic single-line frame), rounded, double, plain, ascii (plain/ascii draw a pure ASCII +-| frame).
    • +
    • pack: widget pack used for every widget without a per-widget override ("default" or "blocks" when compiled with the widget-blocks feature).
    • +
    • widgets: map of widget-name overrides, each accepting charset, borders and pack.
    • +
    + +

    See customization.md for an +example and the full per-widget semantics.

    + +
    +

    Custom Themes and Layouts

    For custom themes and layouts, see the customization guide.

    diff --git a/docs/customization.md b/docs/customization.md index e93ff7d..3988134 100644 --- a/docs/customization.md +++ b/docs/customization.md @@ -37,13 +37,16 @@

    Location

    -

    Place theme files in:

    +

    Theme files live in the themes/ subfolder of the platform config +directory (the same tree as config.json and the layouts):

    -
    ~/.config/xtop/themes/*.jsonc
    -~/.config/xtop/themes/*.json
    +
    ~/.config/xtop/themes/*.jsonc                (Linux)
    +~/Library/Application Support/xtop/themes/   (macOS)
    +%APPDATA%\xtop\themes\                       (Windows)
     
    -

    The directory is created automatically when you save your config on quit.

    +

    The directory and the shipped theme files are created automatically on +first run; no manual copy is needed.

    Format

    @@ -168,24 +171,33 @@

    Starter Themes

    -

    The built-in default theme is x (almost-black background, purple-pink accents). It is compiled into the binary and always available.

    - -

    When you run xtop for the first time, it automatically creates ~/.config/xtop/themes/ with all extra themes embedded in the binary. No manual copy is needed.

    +

    xtop ships 12 themes. The x palette (almost-black +background, purple-pink accents) is compiled into the binary as the startup +fallback; all 12 definitions — including x and miami — +are embedded in the binary as seeding templates. The first run writes them +into the themes directory above, so every shipped theme is available without +copying anything.

    If you want to restore them later, copy from the repository:

    cp -r assets/themes/* ~/.config/xtop/themes/
    -

    Available themes: x, madrid, lahabana, paris, tokio, oslo, helsinki, berlin, london, praha, bogota, miami.

    +

    Available themes: x, berlin, bogota, +helsinki, lahabana, london, madrid, +miami, oslo, paris, praha, +tokio.

    -

    All theme definitions are documented in colors.md.

    +

    All theme palettes are documented in colors.md.

    Loading Order

      -
    1. Built-in miami theme (always available)
    2. -
    3. Themes from ~/.config/xtop/themes/ loaded alphabetically
    4. -
    5. If a custom theme has the same name as miami, it replaces the built-in
    6. +
    7. The compiled-in x palette (startup fallback, index 0).
    8. +
    9. Themes from the themes directory (seeded on first run) load on top; + a file reusing the name x overrides the compiled palette in + place.
    10. +
    11. If a custom theme has the same name as a shipped one, it replaces + it; new names are appended after the shipped set.

    Tips

    @@ -347,19 +359,25 @@

    Starter Layouts

    -

    The 7 built-in layouts are embedded in the binary and written to ~/.config/xtop/layouts/ on first run.

    - -

    To restore them later, copy from the repository:

    +

    The 7 built-in layouts ship in the xtop-layout crate +(github.com/xtop-cli/layouts, folder layouts/default/) and are embedded in the +binary. On first run their JSONC sources are copied to ~/.config/xtop/layouts/ as +editable templates. Community layouts live in layouts/custom/ of the same repo; +install one with xtop layout install <name> (or copy the file into +~/.config/xtop/layouts/). Validate a local file with xtop layout check <file>.

    -
    cp -r assets/layouts/* ~/.config/xtop/layouts/
    +

    A layout file whose name matches a built-in layout overrides it (e.g. +edit dashboard.jsonc to customize the Dashboard). Files with new names show up as +extra layouts.

    -

    Available layouts: dashboard, vertical, horizontal, cpu_focus, memory_focus, network_focus, process_focus.

    +

    Built-in layouts: dashboard, vertical, horizontal, cpu_focus, memory_focus, network_focus, process_focus.

    Cycling Order

    1. Built-in layouts (Dashboard → Vertical → Horizontal → CPU Focus → Memory Focus → Network Focus → Process Focus)
    2. -
    3. Custom layouts from ~/.config/xtop/layouts/ (in filesystem order)
    4. +
    5. Any custom layout from ~/.config/xtop/layouts/ with a new name (filesystem order)
    6. +
    7. Custom files that reuse a built-in name override that built-in in place (no duplicates)
    8. Wraps back to Dashboard
    @@ -368,12 +386,46 @@

    Notes

      -
    • If a widget name in your layout doesn't match any available widget, that area is silently skipped.
    • +
    • If a widget name in your layout doesn't match any available widget, that area is skipped and xtop prints a one-time warning to stderr (xtop: layout '<layout>' references unknown widget '<name>').
    • Nested splits can be arbitrarily deep, but very deep nesting may overflow small terminals.
    • The terminal must be at least 40×8 for any layout to render; smaller terminals show a warning.
    • Very small terminals (under 60×14) fall back to a minimal hardcoded layout (CPU + Memory gauges + process list).
    +

    Widget glyph style

    + +

    Charts (CPU/Memory/Network) and widget borders are drawn with glyph styles +you can change in config.json under the style key (see +configuration.md):

    + +
    {
    +  "theme": "x",
    +  "style": {
    +    "charset": "block",
    +    "borders": "ascii",
    +    "widgets": {
    +      "cpu": { "charset": "bar" },
    +      "network": { "borders": "double" }
    +    }
    +  }
    +}
    + +
      +
    • charset: braille (default), dot, block, half_block, bar.
    • +
    • borders: native (default; the classic single-line box-drawing frame), rounded, double, plain and ascii (both plain and ascii draw a pure ASCII +-| frame).
    • +
    • widgets: per-widget overrides. Keys are the widget names layouts + use: header, cpu, memory, storage, + network, processes, disk_io, battery, gpu. + Each entry accepts charset, borders and an optional + pack (widget pack to render that name with, e.g. "blocks"). + A global style.pack sets the pack for every widget without a + per-widget override.
    • +
    + +

    Glyph styles only change the look: the data behind each widget is drawn by +the widget packs (see plugin.md for how a plugin adds +completely new renderers, which take precedence over packs).

    +

    diff --git a/docs/features.md b/docs/features.md index 18f3c08..59e0629 100644 --- a/docs/features.md +++ b/docs/features.md @@ -55,13 +55,13 @@

    GPU

      -
    • GPU usage gauges (stub implementation, ready for NVIDIA/AMD support).
    • +
    • GPU usage gauges: real data on Linux (NVIDIA via nvidia-smi, AMD/Intel via /sys/class/drm); stub on macOS/Windows.

    Battery

      -
    • Battery charge level gauges (stub implementation, ready for laptop support).
    • +
    • Battery charge level gauges: real data on Linux (/sys/class/power_supply); stub on macOS/Windows.

    @@ -69,10 +69,10 @@

    Theming

      -
    • 13 ready-to-use color schemes built into the binary.
    • +
    • 12 color schemes: 12 JSONC theme files ship in assets/themes/ and are embedded in the binary as first-run seeding templates.
    • Custom themes defined as JSONC files with a 16-entry hex color palette.
    • Instant theme cycling with t (next) and T (previous).
    • -
    • Starter theme files ship in the assets/themes/ directory.
    • +
    • Palette reference in colors.md.

    @@ -101,8 +101,8 @@

    Persistence

      -
    • Current theme, layout mode, update interval, history points, and alert thresholds are saved automatically on quit.
    • -
    • Configuration is stored at ~/.config/xtop/config.json.
    • +
    • Current theme, layout, update interval, history points, alert thresholds and glyph style are saved automatically on quit.
    • +
    • Configuration is stored as config.json in the platform config dir: ~/.config/xtop/ on Linux, ~/Library/Application Support/xtop/ on macOS, %APPDATA%\xtop on Windows.

    diff --git a/docs/installation.md b/docs/installation.md index 5cd063e..4c32128 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -5,8 +5,9 @@

    Table of Contents

      -
    • Quick Install (macOS/Linux)
    • +
    • Quick Install (Linux)
    • Quick Install (Windows)
    • +
    • macOS
    • Installer Options
    • Build from Source
    • Uninstall
    • @@ -15,17 +16,20 @@
      -

      Quick Install (macOS/Linux)

      +

      Quick Install (Linux)

      -

      The installer script automatically detects your distribution and installs all required dependencies, including Rust if needed.

      +

      The installer script detects the distribution and its package manager, +installs the build prerequisites (git and, when missing, the Rust toolchain +via rustup), clones the repository, builds it in release mode and installs +the binary to /usr/local/bin.

      Using curl

      -
      curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash
      +
      curl -fsSL https://raw.githubusercontent.com/xtop-cli/xtop/main/install.sh | bash

      Using wget

      -
      wget -qO- https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash
      +
      wget -qO- https://raw.githubusercontent.com/xtop-cli/xtop/main/install.sh | bash

      @@ -33,7 +37,14 @@

      Requires Rust (Cargo) to be installed. Run in PowerShell:

      -
      irm https://raw.githubusercontent.com/xscriptor/xtop/main/install.ps1 | iex
      +
      irm https://raw.githubusercontent.com/xtop-cli/xtop/main/install.ps1 | iex
      + +
      + +

      macOS

      + +

      There is no dedicated macOS branch in install.sh yet; install a +Rust toolchain (rustup) and build from source as below.


      @@ -67,7 +78,7 @@
      1. Clone the repository:

        -
        git clone https://github.com/xscriptor/xtop.git
        +    
        git clone https://github.com/xtop-cli/xtop.git
         cd xtop
      2. @@ -80,13 +91,16 @@ cd xtop

    Uninstall

    -

    macOS / Linux

    +

    The uninstallers remove the binary only; user configuration under the +config directory is kept.

    + +

    Linux

    -
    curl -fsSL https://raw.githubusercontent.com/xscriptor/xtop/main/install.sh | bash -s -- --uninstall
    +
    curl -fsSL https://raw.githubusercontent.com/xtop-cli/xtop/main/install.sh | bash -s -- --uninstall

    Windows

    -
    irm https://raw.githubusercontent.com/xscriptor/xtop/main/uninstall.ps1 | iex
    +
    irm https://raw.githubusercontent.com/xtop-cli/xtop/main/uninstall.ps1 | iex

    diff --git a/docs/multi-repo.md b/docs/multi-repo.md new file mode 100644 index 0000000..c8550df --- /dev/null +++ b/docs/multi-repo.md @@ -0,0 +1,127 @@ +# Multi-repo architecture (xtop-cli org) + +> Status: **live** (2026-09-04). This document is the ecosystem architecture +> RFC location: it describes how the xtop-cli organization is split into +> repos, how the pieces depend on each other, and where the design is headed. +> Detailed per-area docs live in each repo's `docs/` folder; the push order +> and milestone state live in the root `ROADMAP.md` of the workspace. + +## Organization + +| Repo (xtop-cli) | Role | Content | +|---|---|---| +| `api` | **Contracts** | workspace with the four contract crates: `xtop-plugin-api` (data model, plugin/host traits, `AlertThresholds`, `PluginWidget`), `xtop-widget-api` (pack registration + glyph helpers), `xtop-extension-api` (extension host), `xtop-effect-api` (frame effects) | +| `xtop` | **Kernel** | the app: single-crate binary, `src/` by areas (commands, config, plugins, providers, state, theme, ui). Consumes every other repo | +| `widgets` | **Renderers** | packs of widget renderers against `xtop-widget-api`: base pack `xtop-widgets` + alternative packs (`packs/xtop-widget-blocks`) + `custom/` community | +| `layouts` | **Arrangement** | `xtop-layout` crate: data-driven layout model + JSONC loader + layout modes, plus `layouts/default/` (7 built-ins) and `layouts/custom/` (community, installable) | +| `plugins` | **Functionality** | plugin implementations against `xtop-plugin-api` (first member: `xtop-plugin-samurai`) | +| `extensions` | **Kernel hooks** | server-style extensions against `xtop-extension-api` (`xtop-extension-mcp`) | +| `effects` | **Animation** | frame effects against `xtop-effect-api` (`xtop-effect-fade`: 500 ms fade-in from black) | + +Local development layout (sibling checkouts in one folder, as in this +workspace): + +``` +/home/x/xtop-cli/ + api/ xtop/ widgets/ layouts/ plugins/ extensions/ effects/ +``` + +## Dependency principle: contracts in `api`, consumers point up + +Each consumer repo depends only on the contract crates in `api` — never on +the kernel — so every repo compiles standalone: + +``` + ┌────────────┐ + │ api │ pure contract crates (ratatui/serde only) + └─────┬──────┘ + ┌───────────┼───────────────┬──────────────┐ + ▼ ▼ ▼ ▼ + ┌──────────┐ ┌───────────┐ ┌────────────┐ ┌──────────────┐ + │ xtop │ │ widgets/ │ │ layouts/ │ │ plugins/ │ + │ (kernel) │ │ effects/ │ │ extensions │ │ (samurai) │ + └──────────┘ └───────────┘ └────────────┘ └──────────────┘ +``` + +- **`api`**: pure types + protocols (manifests, capabilities, errors, + snapshot model, provider/widget/extension/effect contracts). Depends only + on `ratatui`/`serde`. Not published to crates.io yet (see Roadmap §6 + follow-ups). +- **`xtop` kernel**: hosts the ecosystem — implements `HostState` / + `WidgetState` / `ExtensionHost` for its live state, runs `PluginManager` + and the composite provider, renders layouts by resolving widget names into + the packs, and drives effects (feature-gated). Builds fine without any + optional feature: `cargo build --no-default-features` is the pure core. +- **`widgets` / `layouts` / `plugins` / `extensions` / `effects`**: consume + only `api` types; each repo compiles standalone and never depends on the + kernel. + +## Integration modes + +| Level | Mechanism | Use | +|---|---|---| +| Compile-time (today) | Cargo git dependencies + optional feature flags | Every integration: samurai plugin, mcp extension, blocks pack, fade effect | +| Dev-time | `xtop plugin install ` (clones, self-edits the kernel `Cargo.toml`, runs `cargo check`) | First steps with a plugin repo | +| Runtime (future, RFC) | binary/ABI discovery of `xtop-plugin-*` / `xtop-effect-*` / `xtop-extension-*` in config dirs + `XTOP_*_DEV_DIR` | Third parties without recompiling — see the root ROADMAP §7 deferred list | + +The kernel never requires any external repo at runtime: optional ecosystem +pieces are Cargo features (`plugin-samurai`, `mcp-extension`, +`widget-blocks`, `effects`); contract crates (the four `xtop-*-api` crates +plus `xtop-widgets`, `xtop-layout`) are unconditional because the kernel's +chrome and state views are written against them. + +## Development flow (temporary path deps) + +The ecosystem repos are edited before they are pushed. To compile a consumer +against un-pushed sibling state, its `Cargo.toml` temporarily replaces the +git dependency with a path dependency (`path = "../api/crates/plugin-api"`, +`path = "../widgets"`, ...), or — when the consumer's own manifest must stay +untouched — a temporary `[patch."https://github.com/xtop-cli/"]` +section redirects the git sources to the sibling checkouts. All temporary +overrides are removed before the owner pushes; final manifests carry the +floating git deps shown above. + +## Why the split exists + +The kernel was originally a workspace of kernel-owned crates +(`xtop-core`/`xtop-tui`/`xtop-cli`), then a monocrate with plugins inside. +Each customization axis grew into its own repo so that: + +- a contributor can ship a widget pack, layout, plugin, extension or effect + without touching the kernel code base; +- every repo compiles against the api contracts alone (testable in + isolation, no kernel import); +- the kernel stays a thin host: metrics model, layouts and widget renderers + all come from the ecosystem crates. + +Single-source rules that keep the split honest (see the root ROADMAP +"decisions" section): + +- the metrics model and plugin protocol exist only in `xtop-plugin-api`; +- widget registration and glyph/style mapping exist only in `xtop-widget-api` + (the plugin-side widget is `xtop_plugin_api::PluginWidget`); +- ecosystem constants live at the producer (`xtop-plugin-samurai` exports + `PLUGIN_ID` and its 12 action names; `xtop-extension-mcp` builds its tool + table from them). + +## Phases + +1. **F0 (done)**: org `xtop-cli`, repos created, local clones in one folder. +2. **F1 (done)**: `api` contract crates extracted from the kernel's domain + model; kernel + siblings consume them. +3. **F2 (done)**: samurai moved to the `plugins` repo; MCP moved to + `extensions`; kernel features point at the sibling repos via git deps. +4. **F3 (done)**: `effects` workspace with the fade effect; kernel wires it + behind the optional `effects` feature. +5. **F4 (done)**: extension host contract + `xtop-extension-mcp` as the + first server-style extension. +6. **F5 (pending)**: publish the api crates to crates.io; tag and pin git + deps; per-repo CI and kernel releases (see the root ROADMAP §6/§7). + +## Open RFC topics + +- Runtime dynamic discovery (ABI or directory-based loading) — explicitly + deferred in the root ROADMAP §7 until the compile-time feature model stops + being coherent. +- Publishing: versioning scheme for the contract crates and the pinning + strategy of every consumer. diff --git a/docs/plugin.md b/docs/plugin.md index 7612148..d8d8974 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -1,64 +1,80 @@

    Plugin System

    -

    xtop has a compile-time plugin system based on Rust feature flags. Plugins live in the plugins/ directory and are registered into the workspace as optional dependencies.

    +

    xtop hosts plugins: extra functionality shipped as separate +crates that implement the contract in xtop-plugin-api (a crate of +the xtop-cli/api repo). Plugins are compile-time: the kernel wires +them through Cargo git dependencies and feature flags, never through runtime +discovery. The built-in plugin is xtop-plugin-samurai +(see multi-repo.md for the ecosystem layout).

    -

    You can build xtop without any plugins:

    +

    You can build xtop without any plugin or extension:

    cargo build --release --no-default-features
    -

    Or with a specific set of plugins:

    +

    Or with the default set (samurai plugin + MCP extension):

    -
    cargo build --release --features plugin-sentinel
    +
    cargo build --release

    Architecture

    -

    The plugin system has four main components:

    +

    The plugin system has four kernel components:

    - + - - - + + + - - + + - - - + + + - - - + + +
    ComponentLocationPurpose
    ComponentKernel locationPurpose
    Plugin traitxtop-core::domain::pluginInterface every plugin must implementPlugin trait + contract typesxtop_plugin_api (crate, external)Interface every plugin implements; manifest, capabilities, errors, data model
    PluginManagerxtop-core::application::plugin_managerLoads, ticks, and dispatches events to pluginssrc/plugins/manager.rsRegisters, ticks and dispatches events to plugins
    CompositeProviderxtop-core::infrastructure::composite_providerMerges data from primary provider + plugin providersHostState implsrc/plugins/host.rsKernel-side view of the live state plugins may touch
    WidgetRegistrationxtop-core::domain::pluginAllows plugins to register custom TUI widgetsCompositeProvidersrc/providers/composite.rsMerges the kernel provider with plugin data providers
    +

    Plugins never depend on the kernel: they see state only through +PluginContext, which is built over the HostState trait. +Plugin widgets render over &dyn HostState through +xtop_plugin_api::PluginWidget (distinct from the widget-pack +registration in xtop-widget-api, which draws over +WidgetState).

    +

    The Plugin Trait

    pub trait Plugin: Debug + Send {
    -    fn manifest(&self) -> PluginManifest;
    -    fn on_enable(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
    -    fn on_disable(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
    -    fn on_tick(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
    -    fn on_key(&mut self, ctx: &mut PluginContext, key: &str) -> Result<bool, PluginError>;
    -    fn data_provider(&self) -> Option<Box<dyn SystemDataProvider>>;
    -    fn widget(&self) -> Option<WidgetRegistration>;
    -    fn execute(&mut self, ctx: &mut PluginContext, action: &str, params: &str) -> Result<String, PluginError>;
    +    fn manifest(&self) -> PluginManifest;
    +    fn on_enable(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
    +    fn on_disable(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
    +    fn on_tick(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError>;
    +    fn on_key(&mut self, ctx: &mut PluginContext, key: &str) -> Result<bool, PluginError>;
    +    fn data_provider(&self) -> Option<Box<dyn SystemDataProvider>>;
    +    fn widget(&self) -> Option<PluginWidget>;
    +    fn execute(&mut self, ctx: &mut PluginContext, action: &str, params: &str)
    +        -> Result<String, PluginError>;
     }
    +

    All methods except manifest() have default implementations, so a +minimal plugin only declares its manifest.

    + @@ -67,64 +83,75 @@ - - - - - + + + + +
    MethodDefaultCalled When
    manifest()requiredAny time metadata is needed
    on_enable()no-opPlugin is registered at startup
    on_disable()no-opxtop shuts down
    on_tick()no-opEvery update cycle (~1s)
    on_key()returns falseKey press (consumes if returns true)
    data_provider()NoneStartup (merged into CompositeProvider)
    widget()NoneEvery tick (refreshes widget registry)
    execute()UnknownActionExternal agent (AI, CLI, IPC) invokes command
    on_tick()no-opEvery update cycle (~1s by default)
    on_key()falseKey press in Normal mode (returns true to consume)
    data_provider()NoneStartup (merged into the CompositeProvider)
    widget()NoneAfter every tick (refreshes the plugin widget map)
    execute()UnknownActionExternal agent (AI, CLI, MCP) invokes a command
    -

    PluginCapability

    +

    PluginManifest

    -

    Each plugin declares what it needs via manifest().capabilities:

    +

    Each plugin declares its identity and needs in +manifest().capabilities:

      -
    • ReadSystemInfo -- access system metrics
    • +
    • ReadSystemInfo -- read system metrics
    • KillProcesses -- terminate processes
    • -
    • ModifyConfig -- change themes, layouts, thresholds
    • +
    • ModifyConfig -- change themes, layouts, thresholds, intervals
    • RenderWidgets -- register custom TUI widgets
    • -
    • Custom(&str) -- anything not covered above
    • +
    • Custom(String) -- anything not covered above

    PluginContext

    -

    Safe, limited access to application state:

    - -
    ctx.snapshot()             // Full SystemSnapshot
    -ctx.top_processes(n)       // Top N processes by CPU
    -ctx.kill_process(pid)      // Kill process by PID
    -ctx.set_alert_thresholds(cpu, mem, disk)
    -ctx.set_theme_by_name("tokio")
    -ctx.set_layout_by_name("Dashboard")
    -ctx.set_update_interval(500)
    -ctx.system_info()          // Hostname, OS, kernel
    -ctx.data_dir()             // ~/.config/xtop/plugins/<id>/
    +

    Safe, limited access to application state. Capability-gated reads return +Result; writes require the matching capability and fail with a +PluginError::Recoverable otherwise:

    + +
    ctx.snapshot()?                 // Full SystemSnapshot (needs ReadSystemInfo)
    +ctx.top_processes(n)?           // Top n processes by CPU, sorted desc
    +ctx.system_info()?              // Hostname, OS, kernel (needs ReadSystemInfo)
    +ctx.kill_process(pid)?          // Kill process by PID (needs KillProcesses)
    +ctx.set_alert_thresholds(cpu, mem, disk)?  // (needs ModifyConfig)
    +ctx.set_theme_by_name("tokio")? // (needs ModifyConfig)
    +ctx.set_layout_by_name("CPU Focus")?       // (needs ModifyConfig)
    +ctx.set_update_interval(500)?   // (needs ModifyConfig)
    +ctx.alerts()                    // Current alert thresholds
    +ctx.config()                    // Theme, layout, interval, hostname
    +ctx.data_dir()                  // Host-provided plugin data dir

    -

    CompositeProvider

    +

    Data Providers

    -

    The CompositeProvider wraps the primary SysinfoProvider and merges data from plugin providers:

    +

    The kernel samples the system with its own provider +(SysinfoProvider in src/providers/sysinfo/) and composes +plugin providers through CompositeProvider:

      -
    • refresh_all() refreshes all providers
    • -
    • snapshot() delegates to primary for CPU, memory, disks, networks, processes
    • -
    • gpu_info(), batteries(), docker_info() check extras if primary returns empty
    • -
    • kill_process() tries primary first, then extras
    • +
    • refresh_all() refreshes the primary provider and every extra
    • +
    • snapshot() delegates to the primary
    • +
    • disk_io(), batteries(), gpu_info() and + system_info() use the primary result when non-empty, otherwise + the first non-empty extra provider result
    • +
    • kill_process() tries the primary first, then the extras

    -

    Widget Registration

    +

    Plugin Widgets

    -

    Plugins can register custom widgets via widget():

    +

    A plugin can register one custom widget via widget(). The render +closure receives the plugin view of the state (&dyn HostState) +and draws with plain ratatui:

    -
    fn widget(&self) -> Option<WidgetRegistration> {
    -    Some(WidgetRegistration {
    -        name: "sentinel".to_string(),
    +
    fn widget(&self) -> Option<PluginWidget> {
    +    Some(PluginWidget {
    +        name: "samurai".to_string(),
             render: Arc::new(|f, state, area| {
    -            // Draw using ratatui
    +            // Draw using ratatui; `state` is &dyn HostState.
             }),
         })
     }
    @@ -137,13 +164,16 @@ ctx.data_dir() // ~/.config/xtop/plugins/<id>/
    "direction": "vertical", "areas": [ { "widget": "header", "size": 3 }, - { "widget": "sentinel", "size": "30%" }, + { "widget": "samurai", "size": "30%" }, { "widget": "processes", "size": "*" } ] } }
    -

    Plugin widgets take precedence over built-in widgets with the same name.

    +

    Plugin widgets take precedence over widget-pack widgets with the same name +(the kernel resolves plugin widgets first at render time). A layout +referencing a name no pack and no plugin provides prints a one-time warning +to stderr.


    @@ -156,11 +186,11 @@ ctx.data_dir() // ~/.config/xtop/plugins/<id>/ xtop plugin list - List installed plugins from workspace members + List plugins wired into the kernel Cargo.toml (feature entries with dep:<name>) xtop plugin install <name> - Install a plugin from github.com/xscriptor/xtop/plugins/ + Install a plugin by name from github.com/xtop-cli/plugins xtop plugin install <url> @@ -168,48 +198,56 @@ ctx.data_dir() // ~/.config/xtop/plugins/<id>/ xtop plugin scaffold <name> - Create a new plugin crate template in plugins/ + Create a new plugin crate template in plugins-dev/ (git-ignored) -

    Install Flow

    +

    Install Flow (real behavior)

    -

    When running xtop plugin install sentinel:

    +

    xtop plugin install does not download a binary and does +not register anything at runtime. It edits the kernel's own +Cargo.toml (a self-modifying-source workflow):

      -
    1. Clones github.com/xscriptor/xtop.git (shallow, sparse)
    2. -
    3. Looks for plugins/xtop-plugin-sentinel/ or plugins/sentinel/ in the clone
    4. -
    5. Copies to local plugins/ directory
    6. -
    7. Adds entry to [workspace].members in root Cargo.toml
    8. -
    9. Adds optional dependency + feature flag in crates/xtop-cli/Cargo.toml
    10. -
    11. Runs cargo build --release
    12. -
    13. Cleans up temporary files
    14. +
    15. Resolves the source repo: github.com/xtop-cli/plugins for a name, or the given URL
    16. +
    17. Clones it (shallow, sparse) into a temp dir
    18. +
    19. Locates the xtop-plugin-<name> crate inside the clone + (repo root, or plugins//crates/ subfolders)
    20. +
    21. Adds an optional git dependency + a feature flag to the kernel's root + Cargo.toml (the same pattern the built-in + xtop-plugin-samurai uses)
    22. +
    23. Runs cargo check to verify the manifest resolves, then cleans up
    -

    The plugin is registered in the workspace but not enabled by default. To enable it:

    +

    The plugin is registered but not enabled by default. To enable +it, add its feature to the default list in [features] in the +kernel Cargo.toml (or build with --features <name>) and +recompile. Every installed plugin also needs a registration line in +src/commands/share/bootstrap.rs under its feature flag.

    -
      -
    • Build with --features plugin-<name> for a one-off build
    • -
    • Add it to the default list in crates/xtop-cli/Cargo.toml to enable permanently
    • -
    - -
    # Build xtop with sentinel plugin enabled
    -cargo build --release --features plugin-sentinel
    +
    # Build xtop with samurai plugin enabled (default already includes it)
    +cargo build --release --features plugin-samurai
     
    -# Build xtop with sentinel + another plugin
    -cargo build --release --features "plugin-sentinel,plugin-mything"
    +# Build xtop with samurai + another plugin +cargo build --release --features "plugin-samurai,plugin-mything"

    -

    MCP Server for AI Agents

    +

    Extensions: the MCP Server

    -

    When the plugin-sentinel feature is enabled, xtop can run an MCP (Model Context Protocol) server on stdio:

    +

    Extensions are the kernel's server-style hooks (contract: +xtop-extension-api). The shipped extension is the MCP +(Model Context Protocol) server in the xtop-cli/extensions repo +(xtop-extension-mcp), which exposes the hosted plugins' actions as +MCP tools over stdio. It is compiled in when the mcp-extension +feature is on (part of the default features).

    xtop mcp
    -

    This exposes Sentinel's commands as MCP tools that any AI assistant can call. -Compatible clients include Claude Desktop, Cline, Cursor, and Continue.dev.

    +

    The MCP tools are executed against the samurai plugin, so the +plugin-samurai feature is required too. Compatible clients include +Claude Desktop, Cline, Cursor, and Continue.dev.

    Claude Desktop configuration

    @@ -230,9 +268,10 @@ echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"system_sum # Interactive session xtop mcp -

    Monitoreo activo (polling)

    - -

    La IA puede llamar system_summary o alerts.status periodicamente. Para monitoreo proactivo (push), haria falta implementar MCP Resources + notificaciones.

    +

    The MCP extension depends on xtop-plugin-samurai at compile time: +the plugin id and the 12 action names are single-sourced constants +(PLUGIN_ID, actions::*), so the tool table can never drift +from the plugin implementation.


    @@ -240,31 +279,29 @@ xtop mcp
    1. -

      Create the crate in plugins/:

      -
      mkdir -p plugins/xtop-plugin-mything/src
      +

      Scaffold the crate (or write it by hand in your own repo):

      +
      xtop plugin scaffold mything   # creates plugins-dev/xtop-plugin-mything/
    2. -

      Add to workspace Cargo.toml:

      -
      [workspace]
      -members = [
      -    ...
      -    "plugins/xtop-plugin-mything",
      -]
      +

      Make it a git repo and push it (the crate must live at the repo root or under + plugins//crates/, e.g. github.com/you/xtop-plugin-mything).

    3. -

      Add dependency + feature in crates/xtop-cli/Cargo.toml:

      +

      Install it into the kernel (adds an optional git dependency + feature flag in the root Cargo.toml):

      +
      xtop plugin install https://github.com/you/xtop-plugin-mything
      +

      Equivalent manual edit:

      [dependencies]
      -xtop-plugin-mything = { path = "../../plugins/xtop-plugin-mything", optional = true }
      +xtop-plugin-mything = { git = "https://github.com/you/xtop-plugin-mything", optional = true }
       
       [features]
       plugin-mything = ["dep:xtop-plugin-mything"]
    4. -

      Register in crates/xtop-cli/src/main.rs:

      +

      Register it behind the feature flag in src/commands/share/bootstrap.rs:

      #[cfg(feature = "plugin-mything")]
       use xtop_plugin_mything::MythingPlugin;
       
      -// In build_plugin_manager():
      +// In register_plugins():
       #[cfg(feature = "plugin-mything")]
       {
           let plugin = Box::new(MythingPlugin::new());
      @@ -275,6 +312,10 @@ use xtop_plugin_mything::MythingPlugin;
         
    +

    Contract details for plugin authors (manifest, capabilities, error types, +widgets) live in the api repo docs; the samurai plugin in +xtop-cli/plugins is the reference implementation.

    +

    diff --git a/docs/usage.md b/docs/usage.md index f226ba6..fccda89 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -96,7 +96,7 @@

    Disk I/O -- Read and write speeds per disk device in bytes per second.

  • -

    Processes -- Scrolling list of the top 50 processes sorted by CPU usage, with live search filtering by process name.

    +

    Processes -- Scrolling list of processes sorted by CPU usage (top 200 by default; override with XTOP_MAX_PROCESSES), with live search filtering by process name.

  • GPU -- GPU usage gauges (available on supported hardware).

    @@ -114,9 +114,9 @@
    -

    Command Palette

    + -

    xtop provides an interactive search overlay for filtering processes in real time:

    +

    Search filters the process list in real time:

    • Press / to open the search bar at the top of the process list.
    • @@ -125,7 +125,18 @@
    • A centered overlay with a /query_ indicator shows the current search input.
    -

    The help overlay (?) serves as a quick-reference command palette for all available keybindings and actions.

    +
    + +

    Command Palette

    + +

    Press ctrl+p to open the command palette, a searchable list of +actions (also reachable with the command_palette keybinding). +The palette has three pages: Main (go to themes/layouts, +toggle or cycle full-screen, search, help, cycle the process sort, random +theme, exit), Themes (jump to any loaded theme) and +Layouts (jump to any layout). Type to filter, use +up/down to move, Enter to run the selected +action and Esc to close.


    @@ -151,19 +162,23 @@ - Wider than 100 cols and taller than 30 rows + Dashboard mode, 100+ cols and 28+ rows Full dashboard layout with 2 columns - Wider than 80 cols and taller than 24 rows + Dashboard mode, 80–99 cols or under 28 rows Compact layout - Narrower than 80 cols + Dashboard mode, narrower than 80 cols Vertically stacked layout - Narrower than 60 cols or shorter than 18 rows + Other modes + The requested mode layout (Vertical, Horizontal, CPU/Memory/Network/Process Focus) + + + Narrower than 60 cols or shorter than 14 rows Minimal layout: CPU, Memory, and Processes only diff --git a/install.ps1 b/install.ps1 index 15c973e..f468335 100644 --- a/install.ps1 +++ b/install.ps1 @@ -4,7 +4,7 @@ $ErrorActionPreference = "Stop" $AppName = "xtop" -$RepoUrl = "https://github.com/xscriptor/xtop.git" # Replace with actual repo URL +$RepoUrl = "https://github.com/xtop-cli/xtop.git" $InstallDir = "$env:USERPROFILE\.cargo\bin" # Standard Cargo bin location Write-Host "Installing $AppName..." -ForegroundColor Green diff --git a/install.sh b/install.sh index a2d7a36..696ebfa 100644 --- a/install.sh +++ b/install.sh @@ -6,9 +6,9 @@ set -euo pipefail APP_NAME="xtop" -REPO_URL="https://github.com/xscriptor/xtop.git" +REPO_URL="https://github.com/xtop-cli/xtop.git" INSTALL_DIR="/usr/local/bin" -VERSION="1.0.0" +VERSION="0.3.0" # Colors GREEN='\033[0;32m' @@ -83,16 +83,16 @@ get_build_deps() { echo "base-devel git" ;; apt) - echo "build-essential git pkg-config libssl-dev" + echo "build-essential git" ;; dnf|yum) - echo "gcc gcc-c++ make git pkg-config openssl-devel" + echo "gcc gcc-c++ make git" ;; zypper) - echo "gcc gcc-c++ make git pkg-config libopenssl-devel" + echo "gcc gcc-c++ make git" ;; apk) - echo "build-base git pkgconfig openssl-dev" + echo "build-base git" ;; *) echo "" @@ -114,7 +114,7 @@ install_build_deps() { if [ -z "$deps" ]; then log_warn "Unknown package manager. Please install build dependencies manually." - log_warn "Required: C compiler, git, pkg-config, OpenSSL development headers" + log_warn "Required: git and a Rust toolchain (rustup installs it automatically)" return 1 fi @@ -203,22 +203,8 @@ check_all_deps() { ((missing++)) fi - # Check for C compiler - if command -v gcc &> /dev/null || command -v clang &> /dev/null; then - log_success "C compiler found" - else - log_warn "No C compiler found (gcc or clang)" - ((missing++)) - fi - - # Check for pkg-config - if command -v pkg-config &> /dev/null; then - log_success "pkg-config found" - else - log_warn "pkg-config not found" - ((missing++)) - fi - + # A C toolchain is not required: xtop and its dependencies are pure Rust. + # git is checked above; cargo is the only remaining hard requirement. if [ $missing -gt 0 ]; then log_warn "$missing dependencies missing" return 1 diff --git a/plugins/xtop-plugin-sentinel/Cargo.toml b/plugins/xtop-plugin-sentinel/Cargo.toml deleted file mode 100644 index 7bb99d8..0000000 --- a/plugins/xtop-plugin-sentinel/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "xtop-plugin-sentinel" -version.workspace = true -edition.workspace = true -license.workspace = true -description = "AI-aware system resource monitoring and management plugin for xtop" - -[dependencies] -xtop-core = { path = "../../crates/xtop-core" } -ratatui.workspace = true -regex = "1" -serde.workspace = true -serde_json.workspace = true -anyhow.workspace = true diff --git a/plugins/xtop-plugin-sentinel/README.md b/plugins/xtop-plugin-sentinel/README.md deleted file mode 100644 index 4128f91..0000000 --- a/plugins/xtop-plugin-sentinel/README.md +++ /dev/null @@ -1,372 +0,0 @@ -

    Sentinel Plugin

    - -

    Sentinel is an AI-aware system monitoring and management plugin for xtop. It exposes system metrics, process information, and configuration through a simple command interface designed to be consumed by AI agents (including LLM-based assistants).

    - -

    It also includes a built-in MCP server (xtop mcp) that exposes all commands as MCP tools for seamless AI integration.

    - -
    - -

    Features

    - -
      -
    • Read system summary (CPU, memory, disks, network, uptime) as JSON
    • -
    • Query top processes with full details and optional regex filter
    • -
    • Search processes by regex on name, cmd, user, or state fields
    • -
    • Kill processes safely by PID
    • -
    • Get and set alert thresholds
    • -
    • Read and update xtop configuration (theme, layout, interval)
    • -
    • Automatic process anomaly detection (CPU spikes, root processes)
    • -
    • Custom TUI widget for visual status
    • -
    • MCP server over stdio for AI tool integration
    • -
    - -
    - -

    Command Interface

    - -

    All interaction happens through a single entry point:

    - -
    plugin.execute(ctx, "<action>", "<params>") -> Result<String, PluginError>
    - -

    The response is always a JSON string, making it easy for any AI or script to parse.

    - -

    system.summary

    - -

    Returns a high-level snapshot of system health.

    - - - - - - -
    ParamsResponse
    (empty)JSON object
    - -

    Example response:

    - -
    {
    -    "cpu_avg": 23.4,
    -    "mem_used_gb": 8.2,
    -    "mem_total_gb": 32.0,
    -    "mem_pct": 26,
    -    "processes": 342,
    -    "disks": 4,
    -    "interfaces": ["en0", "en1"],
    -    "uptime_secs": 482000,
    -    "hostname": "mbp.local"
    -}
    - -

    processes.top

    - -

    Returns the top N processes sorted by CPU usage.

    - - - - - - -
    ParamsResponse
    Count (e.g. "10")JSON array
    - -

    Example response:

    - -
    [
    -    {"pid": 1234, "name": "firefox", "cpu": 45.2, "mem_bytes": 1048576000, "state": "Running", "user": "1000"},
    -    {"pid": 5678, "name": "python3", "cpu": 12.1, "mem_bytes": 524288000, "state": "Sleeping", "user": "1000"}
    -]
    - -

    process.info

    - -

    Returns detailed information about a single process.

    - - - - - - -
    ParamsResponse
    PID (e.g. "1234")JSON object
    - -

    Example response:

    - -
    {
    -    "pid": 1234,
    -    "name": "firefox",
    -    "cpu": 45.2,
    -    "mem_bytes": 1048576000,
    -    "state": "Running",
    -    "cmd": "/usr/lib/firefox/firefox"
    -}
    - -

    process.kill

    - -

    Terminates a process by PID.

    - - - - - - -
    ParamsResponse
    PID (e.g. "1234")JSON object
    - -

    Example response:

    - -
    {"killed": true, "pid": 1234}
    - -

    threshold.set

    - -

    Sets alert thresholds for CPU, memory, and disk usage.

    - - - - - - -
    ParamsResponse
    "cpu,mem,disk" (percentages)JSON object
    - -

    Example: "90,85,80" sets CPU threshold to 90%, memory to 85%, disk to 80%.

    - -

    Response:

    - -
    {"cpu": 90, "mem": 85, "disk": 80, "set": true}
    - -

    threshold.get

    - -

    Returns the current alert thresholds.

    - - - - - - -
    ParamsResponse
    (empty)JSON object
    - -

    Response:

    - -
    {"cpu": 90, "mem": 90, "disk": 90}
    - -

    config.get

    - -

    Returns the current xtop configuration.

    - - - - - - -
    ParamsResponse
    (empty)JSON object
    - -

    Response:

    - -
    {"theme": "x", "layout": "Dashboard", "interval_ms": 1000, "hostname": "mbp.local"}
    - -

    alerts.status

    - -

    Returns any alerts generated by process analysis heuristics.

    - - - - - - -
    ParamsResponse
    (empty)JSON object
    - -

    Response:

    - -
    {"alerts": "high_cpu: firefox (pid=1234, cpu=95.2%)\\nhigh_cpu: python3 (pid=5678, cpu=88.0%)"}
    - -

    processes.search

    - -

    Search processes using a regex pattern. Supports filtering on specific fields.

    - - - - - - -
    ParamsResponse
    "pattern" or "pattern,fields=f1,f2"JSON array of matching processes
    - -

    Supported fields: name, cmd, user, state. Default: name only.

    - -

    Examples:

    - -
    # Search by name
    -processes.search("firefox")
    -
    -# Regex on name
    -processes.search("/python|node/")
    -
    -# Regex on name AND cmd
    -processes.search("/systemd/,fields=name,cmd")
    -
    -# Filter by user
    -processes.search("/^0$/,fields=user")
    - -

    Response:

    - -
    [{"pid": 1234, "name": "firefox", "cpu": 45.2, "mem_bytes": 1048576000, "state": "Running", "user": "1000", "cmd": "/usr/lib/firefox/firefox"}]
    - -

    processes.top (with filter)

    - -

    Top processes with optional regex filter on name or cmd:

    - -
    # Top 5 processes matching the pattern
    -processes.top("5,filter=/firefox/")
    - -

    config.set

    - -

    Update xtop configuration at runtime.

    - - - - - - -
    ParamsResponse
    "interval_ms=<ms>" or "theme=<name>" or "layout=<name>"JSON object
    - -

    Examples:

    - -
    config.set("interval_ms=2000")   # {"interval_ms": 2000, "set": true}
    -config.set("theme=tokio")        # {"theme": "tokio", "set": true}
    -config.set("layout=Vertical")    # {"layout": "Vertical", "set": true}
    - -

    plugin.status

    - -

    Returns the internal state of the Sentinel plugin.

    - - - - - - -
    ParamsResponse
    (empty)JSON object
    - -

    Response:

    - -
    {"enabled": true, "ticks": 42, "last_action": "system.summary", "last_result": "ok (147 chars)"}
    - -
    - -

    TUI Widget

    - -

    Sentinel registers a widget named "sentinel". Include it in any custom layout by referencing it in a JSONC layout file:

    - -
    {
    -    "name": "monitor",
    -    "root": {
    -        "direction": "vertical",
    -        "areas": [
    -            { "widget": "header", "size": 3 },
    -            { "widget": "sentinel", "size": 6 },
    -            { "widget": "processes", "size": "*" }
    -        ]
    -    }
    -}
    - -

    The widget renders a bordered panel with dark background and accent-colored title showing the agent status and a help hint.

    - -
    - -

    MCP Server

    - -

    Sentinel includes a built-in MCP server that exposes all commands as MCP tools. -Run it with:

    - -
    xtop mcp
    - -

    The server speaks JSON-RPC 2.0 over stdin/stdout (standard MCP transport). -Any MCP-compatible AI can connect:

    - -

    Claude Desktop

    - -
    {
    -  "mcpServers": {
    -    "xtop": {
    -      "command": "xtop",
    -      "args": ["mcp"]
    -    }
    -  }
    -}
    - -

    Interactive testing

    - -
    # Call system_summary
    -echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"system_summary","arguments":{}}}' | xtop mcp
    -
    -# Search processes with regex
    -echo '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"processes_search","arguments":{"pattern":"/firefox|chrome/"}}}' | xtop mcp
    -
    -# Kill a process
    -echo '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"process_kill","arguments":{"pid":1234}}}' | xtop mcp
    - -

    Available MCP Tools

    - - - - - - - - - - - - - - - - -
    ToolArgumentsDescription
    system_summary(none)System health summary
    processes_topcount, filter (regex)Top processes
    processes_searchpattern, fieldsRegex search
    process_infopidProcess details
    process_killpidKill process
    threshold_setcpu, mem, diskSet alert thresholds
    threshold_get(none)Get thresholds
    config_get(none)Get config
    config_setinterval_ms / theme / layoutUpdate config
    alerts_status(none)Active alerts
    plugin_status(none)Plugin status
    - -
    - -

    Process Analysis

    - -

    Sentinel includes a heuristic analyzer that runs every 10 ticks (~10 seconds):

    - -
      -
    • Flags processes with CPU usage above 80%
    • -
    • Flags root-owned processes with CPU above 50%
    • -
    • Collects up to 20 alerts per cycle
    • -
    • Alerts are exposed via alerts.status command
    • -
    - -

    The analysis is basic by design. Deeper security heuristics are delegated to domain experts.

    - -
    - -

    AI Integration

    - -

    Sentinel exposes two interfaces for AI agents:

    - -

    Direct execute() (for embedded agents)

    -

    Callable from within xtop's plugin system via PluginManager::execute("sentinel", action, params).

    - -

    MCP protocol (for external AI)

    -

    Run xtop mcp and configure your AI assistant's MCP client to connect. -The AI sees all Sentinel tools as callable functions with typed parameters.

    - -

    Typical AI workflow via MCP:

    - -
      -
    1. AI calls system_summary to get a health overview
    2. -
    3. AI calls processes_top with optional regex filter
    4. -
    5. If suspicious, AI calls process_info for details
    6. -
    7. AI may call process_kill to terminate
    8. -
    9. AI calls alerts_status periodically for anomalies
    10. -
    - -
    - -

    Future Plans

    - -
      -
    • MCP Resources: Push-based monitoring (server notifies AI on alerts)
    • -
    • Real-time streaming: Subscribe to metric changes without polling
    • -
    • Security analysis: Deeper heuristics for malicious process detection
    • -
    • Rich process metadata: Parent PID, executable hash, file descriptors, network sockets
    • -
    - -
    - -

    - Plugin System Docs · - Back to xtop -

    diff --git a/plugins/xtop-plugin-sentinel/src/alert.rs b/plugins/xtop-plugin-sentinel/src/alert.rs deleted file mode 100644 index 4e2266d..0000000 --- a/plugins/xtop-plugin-sentinel/src/alert.rs +++ /dev/null @@ -1,45 +0,0 @@ -use serde::Serialize; - -/// Severity level for a Sentinel alert. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -pub enum Severity { - /// Low-priority, informative - Info, - /// Moderate concern, shown in widget - Warning, - /// High confidence threat - Critical, -} - -/// A single security alert produced by a heuristic rule. -#[derive(Debug, Clone, Serialize)] -pub struct SentinelAlert { - /// Short rule name (e.g. "suspicious_exe_path", "orphan_process") - pub rule: &'static str, - /// Severity level - pub severity: Severity, - /// Process ID that triggered the alert - pub pid: u32, - /// Process name - pub process_name: String, - /// Human-readable detail message - pub message: String, -} - -impl SentinelAlert { - pub fn new( - rule: &'static str, - severity: Severity, - pid: u32, - process_name: String, - message: String, - ) -> Self { - Self { - rule, - severity, - pid, - process_name, - message, - } - } -} diff --git a/plugins/xtop-plugin-sentinel/src/lib.rs b/plugins/xtop-plugin-sentinel/src/lib.rs deleted file mode 100644 index 98d7e71..0000000 --- a/plugins/xtop-plugin-sentinel/src/lib.rs +++ /dev/null @@ -1,883 +0,0 @@ -pub mod alert; -pub mod mcp; - -use ratatui::prelude::*; -use ratatui::widgets::{Block, Borders, Paragraph}; -use regex::Regex; -use std::collections::HashMap; -use std::fmt::Debug; -use xtop_core::application::state::AppState; -use xtop_core::domain::metrics::ProcessInfo; -use xtop_core::domain::plugin::{ - Plugin, PluginCapability, PluginContext, PluginError, PluginManifest, WidgetRegistration, -}; - -use alert::{SentinelAlert, Severity}; - -// --------------------------------------------------------------------------- -// Known threat patterns (Rule 6) -// --------------------------------------------------------------------------- -const KNOWN_THREAT_NAMES: &[&str] = &[ - "minerd", - "cpu_miner", - "xmrig", - "kdevtmpfsi", - "kinsing", - "diagree", - "watchbog", - "sysguard", - "crond64", - "mkfile", - "sysupdate", - "xmrig-nvidia", - "xmrig-amd", - "moneroocean", -]; - -const KNOWN_THREAT_CMDS: &[&str] = &[ - r"--donate-level", - r"--max-cpu-usage", - r"--threads", - r"pool\.monero", - r"pool\.supportxmr", - r"mine\.monero", -]; - -/// Path prefixes that are suspicious for executable locations. -const SUSPICIOUS_PATH_PREFIXES: &[&str] = &[ - "/tmp/", - "/dev/shm/", - "/var/tmp/", - "/proc/", - "/private/tmp/", - "/private/var/tmp/", -]; - -/// Processes allowed to be orphans (PPID=1). -const ALLOWED_ORPHANS: &[&str] = &[ - "systemd", "init", "launchd", "sshd", "login", "getty", "nginx", "apache2", "httpd", "bash", - "sh", "zsh", "tmux", "screen", -]; - -/// Browsers whose children are monitored (Rule 5). -const BROWSER_NAMES: &[&str] = &[ - "chrome", "firefox", "safari", "edge", "brave", "opera", "chromium", -]; - -/// Browser helper/sandbox processes that are allowed children. -const BROWSER_HELPERS: &[&str] = &[ - "helper", - "plugin_container", - "plugin_host", - "gpu_process", - "renderer", - "utility", - "crashpad", - "updater", -]; - -/// Pipe/download patterns for Rule 7. -const PIPE_PATTERNS: &[&str] = &[ - r"curl\s+.*\|\s*(ba|z)?sh", - r"wget\s+.*\|\s*(ba|z)?sh", - r"curl\s+.*\s*ba(?:sh)?\s*$", - r"python3?\s+-c\s+.*(?:import|urllib|requests|socket)", - r"base64\s+-d\s*\|", - r"eval\s*\$\(.*curl", - r"eval\s*\$\(.*wget", - r"bash\s+-c\s+.*\$\(curl", - r"bash\s+-c\s+.*\$\(wget", -]; - -/// High-thread-count processes that are allowed (Rule 8). -const ALLOWED_HIGH_THREAD: &[&str] = &[ - "chrome", "firefox", "code", "Code", "idea", "java", "dotnet", "python", "node", "mysqld", - "postgres", "Xorg", "dockerd", -]; - -/// Maximum alerts per cycle -const MAX_ALERTS: usize = 50; - -// --------------------------------------------------------------------------- -// The plugin struct -// --------------------------------------------------------------------------- - -pub struct SentinelPlugin { - enabled: bool, - tick_count: u64, - last_action: String, - last_action_result: String, - alerts: Vec, - // For spawn storm detection (Rule 10): name -> [(pid, start_time)] - spawn_history: HashMap>, -} - -impl Default for SentinelPlugin { - fn default() -> Self { - Self::new() - } -} - -impl SentinelPlugin { - pub fn new() -> Self { - Self { - enabled: true, - tick_count: 0, - last_action: "none".to_string(), - last_action_result: "ok".to_string(), - alerts: Vec::new(), - spawn_history: HashMap::new(), - } - } - - // ----------------------------------------------------------------------- - // Formatting helpers - // ----------------------------------------------------------------------- - - fn fmt_process(p: &ProcessInfo) -> serde_json::Value { - serde_json::json!({ - "pid": p.pid, - "name": p.name, - "cpu": (p.cpu_usage * 10.0).round() / 10.0, - "mem_bytes": p.memory, - "state": p.state, - "user": p.user_id.as_deref().unwrap_or("?"), - "cmd": p.cmd, - "exe": p.exe_path.as_deref().unwrap_or("?"), - "ppid": p.parent_pid, - "threads": p.thread_count, - "run_time": p.run_time, - "cwd": p.cwd.as_deref().unwrap_or("?"), - }) - } - - fn fmt_process_list(procs: &[ProcessInfo]) -> String { - let entries: Vec = procs.iter().map(Self::fmt_process).collect(); - serde_json::to_string(&entries).unwrap_or_default() - } - - fn fmt_alert(a: &SentinelAlert) -> serde_json::Value { - serde_json::json!({ - "rule": a.rule, - "severity": format!("{:?}", a.severity), - "pid": a.pid, - "process": a.process_name, - "message": a.message, - }) - } - - // ----------------------------------------------------------------------- - // System summary - // ----------------------------------------------------------------------- - - fn system_summary(&self, ctx: &PluginContext) -> String { - let snap = ctx.snapshot(); - let cpu_pct: f64 = - snap.cpus.iter().map(|c| c.usage).sum::() / snap.cpus.len().max(1) as f64; - let mem_gb = (snap.memory.used as f64 / 1073741824.0 * 10.0).round() / 10.0; - let mem_total_gb = (snap.memory.total as f64 / 1073741824.0 * 10.0).round() / 10.0; - let net_ifaces: Vec<&str> = snap.networks.iter().map(|n| n.name.as_str()).collect(); - - serde_json::to_string(&serde_json::json!({ - "cpu_avg": (cpu_pct * 10.0).round() / 10.0, - "mem_used_gb": mem_gb, - "mem_total_gb": mem_total_gb, - "mem_pct": snap.memory.percent.round() as u64, - "processes": snap.processes.len(), - "disks": snap.disks.len(), - "interfaces": net_ifaces, - "uptime_secs": snap.uptime, - "hostname": snap.sys_info.hostname, - "alerts": self.alerts.len(), - })) - .unwrap_or_default() - } - - // ----------------------------------------------------------------------- - // Search / listing - // ----------------------------------------------------------------------- - - fn search_processes(&self, ctx: &PluginContext, params: &str) -> Result { - let (pattern_str, fields) = if let Some(idx) = params.find(",fields=") { - let pat = ¶ms[..idx]; - let fields_part = ¶ms[idx + 8..]; - (pat, fields_part.split(',').collect::>()) - } else { - (params, vec!["name"]) - }; - - let pattern_str = pattern_str.trim(); - if pattern_str.is_empty() { - return Err(PluginError::Recoverable( - "search pattern cannot be empty".into(), - )); - } - - let re = Regex::new(pattern_str) - .map_err(|e| PluginError::Recoverable(format!("invalid regex: {e}")))?; - - let snap = ctx.snapshot(); - let mut matched: Vec = snap - .processes - .into_iter() - .filter(|p| { - fields.iter().any(|f| match *f { - "name" => re.is_match(&p.name), - "cmd" => re.is_match(&p.cmd), - "user" => p.user_id.as_deref().is_some_and(|u| re.is_match(u)), - "state" => re.is_match(&p.state), - "exe" => p.exe_path.as_deref().is_some_and(|e| re.is_match(e)), - "cwd" => p.cwd.as_deref().is_some_and(|c| re.is_match(c)), - _ => false, - }) - }) - .collect(); - - matched.sort_by(|a, b| { - b.cpu_usage - .partial_cmp(&a.cpu_usage) - .unwrap_or(std::cmp::Ordering::Equal) - }); - matched.truncate(100); - Ok(Self::fmt_process_list(&matched)) - } - - fn top_processes(&self, ctx: &PluginContext, params: &str) -> Result { - let (count_str, filter_pattern) = if let Some(idx) = params.find(",filter=") { - let cnt = ¶ms[..idx]; - let pat = ¶ms[idx + 8..]; - (cnt, Some(pat)) - } else { - (params, None) - }; - - let count = count_str.parse::().unwrap_or(10); - if count == 0 { - return Err(PluginError::Recoverable("count must be > 0".into())); - } - let snap = ctx.snapshot(); - let mut procs: Vec = snap.processes; - - if let Some(pattern) = filter_pattern { - let re = Regex::new(pattern) - .map_err(|e| PluginError::Recoverable(format!("invalid regex in filter: {e}")))?; - procs.retain(|p| { - re.is_match(&p.name) - || re.is_match(&p.cmd) - || p.exe_path.as_deref().is_some_and(|e| re.is_match(e)) - }); - } - - procs.truncate(count); - Ok(Self::fmt_process_list(&procs)) - } - - fn process_info(&self, ctx: &PluginContext, pid_str: &str) -> Result { - let pid = pid_str - .parse::() - .map_err(|_| PluginError::Recoverable(format!("invalid pid: {pid_str}")))?; - let snap = ctx.snapshot(); - let proc = snap - .processes - .iter() - .find(|p| p.pid == pid) - .ok_or_else(|| PluginError::Recoverable(format!("process {pid} not found")))?; - Ok(serde_json::to_string(&Self::fmt_process(proc)).unwrap_or_default()) - } - - // ----------------------------------------------------------------------- - // Heuristic rules - // ----------------------------------------------------------------------- - - /// Rule 1: Executable running from a suspicious path. - fn rule_suspicious_exe_path(&self, proc: &ProcessInfo) -> Option { - let exe = proc.exe_path.as_deref()?; - if SUSPICIOUS_PATH_PREFIXES.iter().any(|p| exe.starts_with(p)) { - Some(SentinelAlert::new( - "suspicious_exe_path", - Severity::Critical, - proc.pid, - proc.name.clone(), - format!("executable runs from suspicious path: {exe}"), - )) - } else { - None - } - } - - /// Rule 2: Orphan process (PPID=1) that is not a known system daemon. - fn rule_orphan_process(&self, proc: &ProcessInfo) -> Option { - if proc.parent_pid != Some(1) { - return None; - } - let name_lower = proc.name.to_lowercase(); - if ALLOWED_ORPHANS.iter().any(|a| name_lower.contains(a)) { - return None; - } - let severity = if proc.run_time < 60 { - Severity::Critical - } else { - Severity::Warning - }; - Some(SentinelAlert::new( - "orphan_process", - severity, - proc.pid, - proc.name.clone(), - format!( - "orphan process (PPID=1), running for {}s, exe: {}", - proc.run_time, - proc.exe_path.as_deref().unwrap_or("?"), - ), - )) - } - - /// Rule 3: Process masquerading (name != exe file stem OR system name not at canonical path). - fn rule_masquerading(&self, proc: &ProcessInfo) -> Option { - let exe = proc.exe_path.as_deref()?; - // Extract file stem from exe - let stem = std::path::Path::new(exe) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or(""); - let name_lower = proc.name.to_lowercase(); - let stem_lower = stem.to_lowercase(); - - // Check if name is a known system process but exe is not at a canonical path - let known_system_names = ["svchost", "lsass", "launchd", "sshd", "systemd", "init"]; - if known_system_names.contains(&name_lower.as_str()) { - let canonical = exe.starts_with("/usr/") - || exe.starts_with("/bin/") - || exe.starts_with("/sbin/") - || exe.starts_with("/System/"); - if !canonical { - return Some(SentinelAlert::new( - "process_masquerading", - Severity::Critical, - proc.pid, - proc.name.clone(), - format!( - "process name '{}' masquerades as system process; exe: {exe}", - proc.name - ), - )); - } - } - - // Check if name differs significantly from exe file stem - if name_lower != stem_lower && !stem_lower.is_empty() { - // Allow common cases like "python3" -> exe "/usr/bin/python3.11" - if !exe.contains(&name_lower) && !name_lower.contains(&stem_lower) { - return Some(SentinelAlert::new( - "process_masquerading", - Severity::Warning, - proc.pid, - proc.name.clone(), - format!( - "name '{}' differs from exe stem '{stem}' ({exe})", - proc.name - ), - )); - } - } - - None - } - - /// Rule 4: Privilege escalation (EUID != UID). - fn rule_privilege_escalation(&self, proc: &ProcessInfo) -> Option { - let euid = proc.effective_user_id.as_deref()?; - let uid = proc.user_id.as_deref()?; - if euid == uid { - return None; - } - // Known SUID binaries that are allowed - let known_suid = [ - "/usr/bin/sudo", - "/usr/bin/passwd", - "/bin/ping", - "/usr/bin/ping", - "/bin/su", - "/usr/bin/su", - "/usr/bin/newgrp", - "/usr/bin/gpasswd", - "/usr/bin/chsh", - "/usr/bin/chfn", - "/usr/bin/mount", - "/usr/bin/umount", - ]; - let exe = proc.exe_path.as_deref().unwrap_or(""); - if known_suid.contains(&exe) { - return None; - } - let severity = if euid == "0" { - Severity::Critical - } else { - Severity::Warning - }; - Some(SentinelAlert::new( - "privilege_escalation", - severity, - proc.pid, - proc.name.clone(), - format!("EUID ({euid}) != UID ({uid}), exe: {exe}"), - )) - } - - /// Rule 5: Suspicious child of a browser process. - fn rule_suspicious_child_of_browser( - &self, - proc: &ProcessInfo, - parent_map: &HashMap, - ) -> Option { - let ppid = proc.parent_pid?; - let parent = parent_map.get(&ppid)?; - let parent_lower = parent.name.to_lowercase(); - let is_browser = BROWSER_NAMES.iter().any(|b| parent_lower.contains(b)); - if !is_browser { - return None; - } - let child_lower = proc.name.to_lowercase(); - let is_helper = BROWSER_HELPERS.iter().any(|h| child_lower.contains(h)); - if is_helper { - return None; - } - Some(SentinelAlert::new( - "suspicious_child_of_browser", - Severity::Warning, - proc.pid, - proc.name.clone(), - format!( - "browser '{}' spawned unknown child '{}'", - parent.name, proc.name - ), - )) - } - - /// Rule 6: Known threat pattern (name or cmd matches miner/rootkit names). - fn rule_known_threat_pattern(&self, proc: &ProcessInfo) -> Option { - let name_lower = proc.name.to_lowercase(); - if KNOWN_THREAT_NAMES.iter().any(|t| name_lower.contains(t)) { - return Some(SentinelAlert::new( - "known_threat_pattern", - Severity::Critical, - proc.pid, - proc.name.clone(), - format!("process name matches known threat pattern: {}", proc.name), - )); - } - let cmd_joined = proc.cmd_full.join(" ").to_lowercase(); - if KNOWN_THREAT_CMDS.iter().any(|t| { - Regex::new(t) - .ok() - .is_some_and(|re| re.is_match(&cmd_joined)) - }) { - return Some(SentinelAlert::new( - "known_threat_pattern", - Severity::Critical, - proc.pid, - proc.name.clone(), - format!("command line matches known threat pattern: {}", proc.cmd), - )); - } - None - } - - /// Rule 7: Suspicious pipe/download pattern in command line. - fn rule_suspicious_pipe_or_download(&self, proc: &ProcessInfo) -> Option { - let cmd_joined = proc.cmd_full.join(" "); - for pattern in PIPE_PATTERNS { - if let Ok(re) = Regex::new(pattern) { - if re.is_match(&cmd_joined) { - return Some(SentinelAlert::new( - "suspicious_pipe_or_download", - Severity::Critical, - proc.pid, - proc.name.clone(), - format!("command matches pipe/download pattern: {}", proc.cmd), - )); - } - } - } - None - } - - /// Rule 8: High thread count anomaly. - fn rule_high_thread_anomaly(&self, proc: &ProcessInfo) -> Option { - if proc.thread_count < 500 { - return None; - } - let name_lower = proc.name.to_lowercase(); - if ALLOWED_HIGH_THREAD.iter().any(|a| name_lower.contains(a)) { - return None; - } - let severity = if proc.thread_count > 1000 || proc.cpu_usage > 200.0 { - Severity::Critical - } else { - Severity::Warning - }; - Some(SentinelAlert::new( - "high_thread_anomaly", - severity, - proc.pid, - proc.name.clone(), - format!( - "{} threads (CPU: {:.1}%)", - proc.thread_count, proc.cpu_usage - ), - )) - } - - /// Rule 9: Suspicious file descriptor anomaly. - fn rule_suspicious_fd_anomaly(&self, proc: &ProcessInfo) -> Option { - if proc.open_files < 1000 { - return None; - } - let name_lower = proc.name.to_lowercase(); - let allowed = [ - "mysql", "postgres", "nginx", "httpd", "apache", "chrome", "firefox", "code", "java", - "dotnet", "dockerd", - ]; - if allowed.iter().any(|a| name_lower.contains(a)) { - return None; - } - Some(SentinelAlert::new( - "suspicious_fd_anomaly", - Severity::Info, - proc.pid, - proc.name.clone(), - format!("{} open file descriptors", proc.open_files), - )) - } - - /// Rule 10: Spawn storm detection. - fn rule_spawn_storm(&mut self, proc: &ProcessInfo, now_run_time: u64) -> Option { - if proc.run_time > 120 { - return None; - } - let entry = self.spawn_history.entry(proc.name.clone()).or_default(); - entry.push((proc.pid, proc.start_time)); - // Purge entries older than 120s - entry.retain(|(_, start)| now_run_time.saturating_sub(*start) < 120); - if entry.len() > 5 { - Some(SentinelAlert::new( - "recent_spawn_storm", - Severity::Warning, - proc.pid, - proc.name.clone(), - format!("{} new instances in last 120s", entry.len()), - )) - } else { - None - } - } - - // ----------------------------------------------------------------------- - // Main analyzer - // ----------------------------------------------------------------------- - - fn analyze_processes(&mut self, ctx: &PluginContext) -> Vec { - let snap = ctx.snapshot(); - let mut alerts: Vec = Vec::new(); - - // Build parent PID map for Rule 5 - let parent_map: HashMap = - snap.processes.iter().map(|p| (p.pid, p)).collect(); - - let now_run_time = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - for proc in &snap.processes { - // Run rules in priority order - if let Some(a) = self.rule_suspicious_exe_path(proc) { - alerts.push(a); - } - if let Some(a) = self.rule_masquerading(proc) { - alerts.push(a); - } - if let Some(a) = self.rule_known_threat_pattern(proc) { - alerts.push(a); - } - if let Some(a) = self.rule_suspicious_pipe_or_download(proc) { - alerts.push(a); - } - if let Some(a) = self.rule_orphan_process(proc) { - alerts.push(a); - } - if let Some(a) = self.rule_privilege_escalation(proc) { - alerts.push(a); - } - if let Some(a) = self.rule_suspicious_child_of_browser(proc, &parent_map) { - alerts.push(a); - } - if let Some(a) = self.rule_high_thread_anomaly(proc) { - alerts.push(a); - } - if let Some(a) = self.rule_suspicious_fd_anomaly(proc) { - alerts.push(a); - } - if let Some(a) = self.rule_spawn_storm(proc, now_run_time) { - alerts.push(a); - } - } - - alerts.truncate(MAX_ALERTS); - alerts - } - - fn parse_thresholds(params: &str) -> Result<(f64, f64, f64), PluginError> { - let parts: Vec<&str> = params.split(',').collect(); - if parts.len() != 3 { - return Err(PluginError::Recoverable( - "expected cpu,mem,disk (3 comma-separated values)".into(), - )); - } - let cpu = parts[0] - .parse::() - .map_err(|e| PluginError::Recoverable(format!("invalid cpu threshold: {e}")))?; - let mem = parts[1] - .parse::() - .map_err(|e| PluginError::Recoverable(format!("invalid mem threshold: {e}")))?; - let disk = parts[2] - .parse::() - .map_err(|e| PluginError::Recoverable(format!("invalid disk threshold: {e}")))?; - Ok((cpu, mem, disk)) - } -} - -impl Debug for SentinelPlugin { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("SentinelPlugin") - .field("enabled", &self.enabled) - .field("tick_count", &self.tick_count) - .field("alerts", &self.alerts.len()) - .finish() - } -} - -impl Plugin for SentinelPlugin { - fn manifest(&self) -> PluginManifest { - PluginManifest { - id: "sentinel".to_string(), - name: "Sentinel".to_string(), - version: "0.1.0".to_string(), - description: "AI-aware system monitoring, management, and heuristic threat detection" - .to_string(), - capabilities: vec![ - PluginCapability::ReadSystemInfo, - PluginCapability::KillProcesses, - PluginCapability::ModifyConfig, - PluginCapability::RenderWidgets, - ], - } - } - - fn on_enable(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> { - self.enabled = true; - Ok(()) - } - - fn on_disable(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> { - self.enabled = false; - Ok(()) - } - - fn on_tick(&mut self, ctx: &mut PluginContext) -> Result<(), PluginError> { - self.tick_count += 1; - if self.tick_count.is_multiple_of(5) { - self.alerts = self.analyze_processes(ctx); - } - Ok(()) - } - - fn widget(&self) -> Option { - Some(WidgetRegistration { - name: "sentinel".to_string(), - render: std::sync::Arc::new(|f: &mut ratatui::Frame, _state: &AppState, area: Rect| { - use xtop_core::domain::theme::hex_to_rgb_pub; - let bg = hex_to_rgb_pub("#1a1b2e"); - let fg = hex_to_rgb_pub("#7ec8e3"); - let accent = hex_to_rgb_pub("#c084fc"); - - let block = Block::default() - .title(" Sentinel ") - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Rgb(accent[0], accent[1], accent[2]))) - .style( - Style::default() - .bg(Color::Rgb(bg[0], bg[1], bg[2])) - .fg(Color::Rgb(fg[0], fg[1], fg[2])), - ); - let inner = block.inner(area); - f.render_widget(block, area); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Min(0), - ]) - .split(inner); - - let status = Paragraph::new("Agent: monitoring for threats -- use MCP to interact") - .style(Style::default().fg(Color::Rgb(accent[0], accent[1], accent[2]))); - f.render_widget(status, chunks[0]); - - let info = Paragraph::new("run xtop mcp for AI tool integration") - .style(Style::default().fg(Color::Rgb(fg[0], fg[1], fg[2]))); - f.render_widget(info, chunks[1]); - }), - }) - } - - fn execute( - &mut self, - ctx: &mut PluginContext, - action: &str, - params: &str, - ) -> Result { - self.last_action = format!("{}({})", action, params); - - let result = match action { - "system.summary" => Ok(self.system_summary(ctx)), - "processes.top" => self.top_processes(ctx, params), - "processes.search" => self.search_processes(ctx, params), - "process.info" => self.process_info(ctx, params), - "process.kill" => { - let pid = params - .parse::() - .map_err(|_| PluginError::Recoverable(format!("invalid pid: {params}")))?; - let ok = ctx - .kill_process(pid) - .map_err(|e| PluginError::Recoverable(e.to_string()))?; - Ok(serde_json::to_string(&serde_json::json!({ - "killed": ok, - "pid": pid, - })) - .unwrap_or_default()) - } - "process.alerts" => { - let alerts_json: Vec = - self.alerts.iter().map(Self::fmt_alert).collect(); - Ok(serde_json::to_string(&alerts_json).unwrap_or_default()) - } - "threshold.set" => { - let (cpu, mem, disk) = Self::parse_thresholds(params)?; - ctx.set_alert_thresholds(cpu, mem, disk) - .map_err(|e| PluginError::Recoverable(e.to_string()))?; - Ok(serde_json::to_string(&serde_json::json!({ - "cpu": cpu, "mem": mem, "disk": disk, "set": true, - })) - .unwrap_or_default()) - } - "threshold.get" => { - let alerts = ctx.state().alerts; - Ok(serde_json::to_string(&serde_json::json!({ - "cpu": alerts.cpu_high, - "mem": alerts.mem_high, - "disk": alerts.disk_high, - })) - .unwrap_or_default()) - } - "config.get" => { - let s = ctx.state(); - Ok(serde_json::to_string(&serde_json::json!({ - "theme": s.current_theme.name, - "layout": s.current_layout_name(), - "interval_ms": s.update_interval_ms, - "hostname": s.sys_info.hostname, - })) - .unwrap_or_default()) - } - "config.set" => { - if let Some(val) = params.strip_prefix("interval_ms=") { - let ms = val.parse::().map_err(|e| { - PluginError::Recoverable(format!("invalid interval_ms: {e}")) - })?; - ctx.set_update_interval(ms) - .map_err(|e| PluginError::Recoverable(e.to_string()))?; - Ok(serde_json::to_string(&serde_json::json!({ - "interval_ms": ms, "set": true, - })) - .unwrap_or_default()) - } else if let Some(name) = params.strip_prefix("theme=") { - let ok = ctx - .set_theme_by_name(name) - .map_err(|e| PluginError::Recoverable(e.to_string()))?; - Ok(serde_json::to_string(&serde_json::json!({ - "theme": name, "set": ok, - })) - .unwrap_or_default()) - } else if let Some(name) = params.strip_prefix("layout=") { - let ok = ctx - .set_layout_by_name(name) - .map_err(|e| PluginError::Recoverable(e.to_string()))?; - Ok(serde_json::to_string(&serde_json::json!({ - "layout": name, "set": ok, - })) - .unwrap_or_default()) - } else { - Err(PluginError::Recoverable( - "expected interval_ms=, theme=, or layout=".into(), - )) - } - } - "alerts.status" => { - let critical = self - .alerts - .iter() - .filter(|a| matches!(a.severity, Severity::Critical)) - .count(); - let warning = self - .alerts - .iter() - .filter(|a| matches!(a.severity, Severity::Warning)) - .count(); - let info_count = self - .alerts - .iter() - .filter(|a| matches!(a.severity, Severity::Info)) - .count(); - let top: Vec = - self.alerts.iter().take(5).map(Self::fmt_alert).collect(); - Ok(serde_json::to_string(&serde_json::json!({ - "total": self.alerts.len(), - "critical": critical, - "warning": warning, - "info": info_count, - "alerts": top, - })) - .unwrap_or_default()) - } - "plugin.status" => { - let critical = self - .alerts - .iter() - .filter(|a| matches!(a.severity, Severity::Critical)) - .count(); - Ok(serde_json::to_string(&serde_json::json!({ - "enabled": self.enabled, - "ticks": self.tick_count, - "last_action": self.last_action, - "last_result": self.last_action_result, - "active_alerts": self.alerts.len(), - "critical_alerts": critical, - })) - .unwrap_or_default()) - } - _ => { - return Err(PluginError::UnknownAction(action.to_string())); - } - }; - - match &result { - Ok(r) => self.last_action_result = format!("ok ({} chars)", r.len()), - Err(e) => self.last_action_result = format!("error: {e}"), - } - - result - } -} diff --git a/plugins/xtop-plugin-sentinel/src/mcp.rs b/plugins/xtop-plugin-sentinel/src/mcp.rs deleted file mode 100644 index d6a34c4..0000000 --- a/plugins/xtop-plugin-sentinel/src/mcp.rs +++ /dev/null @@ -1,331 +0,0 @@ -//! MCP (Model Context Protocol) server for the Sentinel plugin. -//! -//! Runs on stdio transport and exposes Sentinel's `execute()` commands as MCP tools. -//! Any MCP-compatible AI (Claude Desktop, Cline, etc.) can connect via: -//! -//! ```json -//! { -//! "mcpServers": { -//! "xtop": { -//! "command": "xtop", -//! "args": ["mcp"] -//! } -//! } -//! } -//! ``` -//! -//! Protocol: JSON-RPC 2.0 over stdin/stdout (one JSON object per line). - -use std::io::{self, BufRead, Write}; -use xtop_core::application::state::AppState; - -const SERVER_NAME: &str = "xtop-sentinel"; -const SERVER_VERSION: &str = "0.1.0"; -const PROTOCOL_VERSION: &str = "2024-11-05"; - -/// Run the MCP server loop. -/// -/// Reads JSON-RPC messages from stdin, processes them via the Sentinel plugin's -/// `execute()` interface, and writes responses to stdout. -/// -/// `state` must already have the Sentinel plugin registered in its PluginManager. -pub fn run_server(state: &mut AppState) -> anyhow::Result<()> { - let stdin = io::stdin(); - let stdout = io::stdout(); - let mut stdout_lock = stdout.lock(); - - for line in stdin.lock().lines() { - let line = line.map_err(|e| anyhow::anyhow!("stdin read error: {e}"))?; - let line = line.trim().to_string(); - if line.is_empty() { - continue; - } - - let parsed: serde_json::Value = - serde_json::from_str(&line).map_err(|e| anyhow::anyhow!("invalid JSON-RPC: {e}"))?; - - let id = parsed.get("id").cloned(); - let method = parsed.get("method").and_then(|m| m.as_str()).unwrap_or(""); - - let params = parsed - .get("params") - .cloned() - .unwrap_or(serde_json::Value::Null); - - let response = match method { - "initialize" => handle_initialize(id, ¶ms), - "tools/list" => handle_tools_list(id), - "tools/call" => handle_tools_call(id, ¶ms, state), - _ => make_error(id, -32601, format!("Method not found: {method}")), - }; - - let response_line = serde_json::to_string(&response)?; - writeln!(stdout_lock, "{response_line}")?; - stdout_lock.flush()?; - } - - Ok(()) -} - -// --------------------------------------------------------------------------- -// JSON-RPC helpers -// --------------------------------------------------------------------------- - -fn make_result(id: Option, result: serde_json::Value) -> serde_json::Value { - serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "result": result - }) -} - -fn make_error(id: Option, code: i32, message: String) -> serde_json::Value { - serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "error": { "code": code, "message": message } - }) -} - -// --------------------------------------------------------------------------- -// MCP: initialize -// --------------------------------------------------------------------------- - -fn handle_initialize( - id: Option, - _params: &serde_json::Value, -) -> serde_json::Value { - make_result( - id, - serde_json::json!({ - "protocolVersion": PROTOCOL_VERSION, - "capabilities": { "tools": {} }, - "serverInfo": { "name": SERVER_NAME, "version": SERVER_VERSION } - }), - ) -} - -// --------------------------------------------------------------------------- -// MCP: tools/list -// --------------------------------------------------------------------------- - -fn handle_tools_list(id: Option) -> serde_json::Value { - make_result( - id, - serde_json::json!({ - "tools": [ - { - "name": "system_summary", - "description": "Get a high-level system health summary (CPU, memory, disks, network, uptime, hostname)", - "inputSchema": { "type": "object", "properties": {} } - }, - { - "name": "processes_top", - "description": "Get top N processes by CPU usage, with optional regex filter", - "inputSchema": { - "type": "object", - "properties": { - "count": { "type": "integer", "description": "Number of processes (default 10)", "default": 10 }, - "filter": { "type": "string", "description": "Optional regex to filter by name or command" } - } - } - }, - { - "name": "processes_search", - "description": "Search processes using regex. Fields: name, cmd, user, state, exe, cwd", - "inputSchema": { - "type": "object", - "properties": { - "pattern": { "type": "string", "description": "Regex pattern" }, - "fields": { "type": "string", "description": "Fields to search: name,cmd,user,state,exe,cwd (default: name)" } - }, - "required": ["pattern"] - } - }, - { - "name": "process_info", - "description": "Get detailed info about a process by PID (includes exe, ppid, threads, cwd)", - "inputSchema": { - "type": "object", - "properties": { - "pid": { "type": "integer", "description": "Process ID" } - }, - "required": ["pid"] - } - }, - { - "name": "process_kill", - "description": "Terminate a process by PID", - "inputSchema": { - "type": "object", - "properties": { - "pid": { "type": "integer", "description": "Process ID to kill" } - }, - "required": ["pid"] - } - }, - { - "name": "threshold_set", - "description": "Set alert thresholds for CPU, memory, and disk (percentages)", - "inputSchema": { - "type": "object", - "properties": { - "cpu": { "type": "number", "description": "CPU threshold" }, - "mem": { "type": "number", "description": "Memory threshold" }, - "disk": { "type": "number", "description": "Disk threshold" } - }, - "required": ["cpu", "mem", "disk"] - } - }, - { - "name": "threshold_get", - "description": "Get current alert threshold values", - "inputSchema": { "type": "object", "properties": {} } - }, - { - "name": "config_get", - "description": "Get current xtop configuration", - "inputSchema": { "type": "object", "properties": {} } - }, - { - "name": "config_set", - "description": "Update configuration: interval_ms, theme, or layout", - "inputSchema": { - "type": "object", - "properties": { - "interval_ms": { "type": "integer", "description": "Update interval in milliseconds" }, - "theme": { "type": "string", "description": "Theme name" }, - "layout": { "type": "string", "description": "Layout name" } - } - } - }, - { - "name": "process_alerts", - "description": "Get all heuristic alerts as a JSON array (suspicious_exe_path, masquerading, known_threat, pipe_download, orphan, privilege_escalation, browser_child, thread_anomaly, fd_anomaly, spawn_storm)", - "inputSchema": { "type": "object", "properties": {} } - }, - { - "name": "alerts_status", - "description": "Get alert summary with counts by severity (critical, warning, info)", - "inputSchema": { "type": "object", "properties": {} } - }, - { - "name": "plugin_status", - "description": "Get Sentinel plugin internal status", - "inputSchema": { "type": "object", "properties": {} } - } - ] - }), - ) -} - -// --------------------------------------------------------------------------- -// MCP: tools/call -// --------------------------------------------------------------------------- - -fn handle_tools_call( - id: Option, - params: &serde_json::Value, - state: &mut AppState, -) -> serde_json::Value { - let name = params.get("name").and_then(|n| n.as_str()).unwrap_or(""); - let args = params - .get("arguments") - .cloned() - .unwrap_or(serde_json::Value::Null); - - // Map MCP tool name -> Sentinel action + params string - let (action, params_str): (&str, String) = match name { - "system_summary" => ("system.summary", String::new()), - - "processes_top" => { - let count = args.get("count").and_then(|c| c.as_i64()).unwrap_or(10); - let filter = args.get("filter").and_then(|f| f.as_str()); - let p = match filter { - Some(f) => format!("{count},filter={f}"), - None => count.to_string(), - }; - ("processes.top", p) - } - - "processes_search" => { - let pattern = args.get("pattern").and_then(|p| p.as_str()).unwrap_or(""); - let fields = args.get("fields").and_then(|f| f.as_str()); - let p = match fields { - Some(f) => format!("{pattern},fields={f}"), - None => pattern.to_string(), - }; - ("processes.search", p) - } - - "process_info" => { - let pid = match args.get("pid").and_then(|p| p.as_i64()) { - Some(p) => p.to_string(), - None => return make_error(id, -32602, "missing required argument: pid".into()), - }; - ("process.info", pid) - } - - "process_kill" => { - let pid = match args.get("pid").and_then(|p| p.as_i64()) { - Some(p) => p.to_string(), - None => return make_error(id, -32602, "missing required argument: pid".into()), - }; - ("process.kill", pid) - } - - "threshold_set" => { - let cpu = match args.get("cpu").and_then(|c| c.as_f64()) { - Some(v) => v.to_string(), - None => return make_error(id, -32602, "missing required argument: cpu".into()), - }; - let mem = match args.get("mem").and_then(|m| m.as_f64()) { - Some(v) => v.to_string(), - None => return make_error(id, -32602, "missing required argument: mem".into()), - }; - let disk = match args.get("disk").and_then(|d| d.as_f64()) { - Some(v) => v.to_string(), - None => return make_error(id, -32602, "missing required argument: disk".into()), - }; - ("threshold.set", format!("{cpu},{mem},{disk}")) - } - - "threshold_get" => ("threshold.get", String::new()), - "config_get" => ("config.get", String::new()), - - "config_set" => { - if let Some(ms) = args.get("interval_ms").and_then(|v| v.as_i64()) { - ("config.set", format!("interval_ms={ms}")) - } else if let Some(theme) = args.get("theme").and_then(|v| v.as_str()) { - ("config.set", format!("theme={theme}")) - } else if let Some(layout) = args.get("layout").and_then(|v| v.as_str()) { - ("config.set", format!("layout={layout}")) - } else { - return make_error(id, -32602, "expected interval_ms, theme, or layout".into()); - } - } - - "process_alerts" => ("process.alerts", String::new()), - "alerts_status" => ("alerts.status", String::new()), - "plugin_status" => ("plugin.status", String::new()), - - _ => return make_error(id, -32601, format!("Tool not found: {name}")), - }; - - // Tick to refresh data (also ticks plugins) - state.on_tick(); - - // Execute via Sentinel plugin - let result_str = state - .with_plugin_manager_mut(|mgr, this| mgr.execute(this, "sentinel", action, ¶ms_str)); - - match result_str { - Ok(json_str) => make_result( - id, - serde_json::json!({ - "content": [{"type": "text", "text": json_str}] - }), - ), - Err(e) => make_error(id, -32000, e.to_string()), - } -} diff --git a/scripts/audit.sh b/scripts/audit.sh new file mode 100755 index 0000000..3d407a8 --- /dev/null +++ b/scripts/audit.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash +# Structural audit for the xtop kernel (ROADMAP #47). +# +# Failing thresholds (exit 1 when violated): +# - cfg(target_os) outside platform/ trees : must be 0 +# - files over 600 lines : must be 0 +# - TODO/FIXME/XXX/HACK markers : must be 0 +# - `pub use ...::*` wildcard re-exports : <= 30 +# - LOC per top-level area : <= 2400 +# - dead src/commands/plugins_dir_tmp/ tree : must be absent +# - theme seeds embedded (miami included, 12 total): must hold +# +# Non-failing metrics are printed for tracking (LOC, module count). +# Not implemented (deferred): module dependency graph/cycles, unused-pub +# detection. See the ROADMAP R3 entry for the follow-up note. +set -u +cd "$(dirname "$0")/.." || exit 1 +SRC=src + +fail=0 +total_loc=0 + +echo "== xtop structural audit ==" + +# --- cfg(target_os) only inside platform/ trees --------------------------------- +bad_cfg=$(grep -rn "cfg(target_os" "$SRC" | grep -v "/platform/" | wc -l) +echo "cfg(target_os) outside platform/ trees: $bad_cfg (must be 0)" +[ "$bad_cfg" -ne 0 ] && fail=1 + +# --- oversized files -------------------------------------------------------------- +echo "-- files > 300 lines (watch) and > 600 (fail):" +big600=$(find "$SRC" -name '*.rs' -exec wc -l {} + | awk '$1 > 600 && $2 != "total" {print $2": "$1" lines"}') +big300=$(find "$SRC" -name '*.rs' -exec wc -l {} + | awk '$1 > 300 && $2 != "total" {print $2": "$1" lines"}') +if [ -n "$big600" ]; then + echo "$big600" + echo " ^ files over 600 lines" + fail=1 +fi +[ -n "$big300" ] && echo "$big300" || echo " none > 300" + +# --- TODO/FIXME markers ------------------------------------------------------------- +todos=$(grep -rn "TODO\|FIXME\|XXX\|HACK" "$SRC" | wc -l) +echo "TODO/FIXME/XXX/HACK markers: $todos (must be 0)" +[ "$todos" -ne 0 ] && fail=1 + +# --- wildcard re-exports ------------------------------------------------------------- +wild=$(grep -rn "pub use .*::\*" "$SRC" | wc -l) +echo "wildcard 'pub use ...::*': $wild (allow <= 30)" +[ "$wild" -gt 30 ] && fail=1 + +# --- LOC per top-level area ------------------------------------------------------------ +echo "-- LOC per area:" +for area in "$SRC"/*; do + [ -d "$area" ] || continue + loc=$(find "$area" -name '*.rs' -exec cat {} + | wc -l) + total_loc=$((total_loc + loc)) + name=$(basename "$area") + echo " $name: $loc" + [ "$loc" -gt 2400 ] && { echo " ^ exceeds 2400"; fail=1; } +done +main_loc=$(wc -l < "$SRC/main.rs") +total_loc=$((total_loc + main_loc)) +echo "main.rs: $main_loc / total kernel: $total_loc" + +# --- dead pre-monocrate leftover tree ------------------------------------------------- +if [ -d "$SRC/commands/plugins_dir_tmp" ]; then + echo "dead src/commands/plugins_dir_tmp/ tree still present (must be absent)" + fail=1 +else + echo "dead src/commands/plugins_dir_tmp/ tree: absent (ok)" +fi + +# --- theme seeds match the shipped set ------------------------------------------------ +seed_file="$SRC/commands/share/assets.rs" +seeds=$(grep -c 'include_str!("../../../assets/themes/' "$seed_file") +miami_seeded=$(grep -c '"miami", include_str!("../../../assets/themes/miami.jsonc")' "$seed_file") +echo "embedded theme seeds: $seeds (expect 12, miami included)" +[ "$seeds" -eq 12 ] || fail=1 +[ "$miami_seeded" -ge 1 ] || { echo " ^ miami missing from the embedded seeds"; fail=1; } + +# --- module graph sanity (imports of kernel areas from lower layers) ------------------- +echo "cross-area imports (info):" +grep -rn "use crate::" "$SRC" --include='*.rs' | awk -F'::' '{print $0}' | wc -l | xargs echo " total use crate:: lines:" + +if [ "$fail" -eq 0 ]; then + echo "AUDIT OK" +else + echo "AUDIT FAILED" +fi +exit $fail diff --git a/scripts/ci.sh b/scripts/ci.sh new file mode 100755 index 0000000..9574383 --- /dev/null +++ b/scripts/ci.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Local CI for the xtop kernel repo (single-crate layout). +# +# Intentionally NOT wired into git: no GitHub Actions, no git hooks. Run it +# yourself from the repo root: +# +# ./scripts/ci.sh # run every stage +# ./scripts/ci.sh fmt # run one stage +# +# Stages: fmt | clippy | check | test | no-default +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +if [[ ! -f Cargo.toml ]]; then + echo "[ci] no Cargo.toml at repo root; nothing to run." + exit 0 +fi + +stages=(fmt clippy check test no-default) +requested=("$@") +if [[ ${#requested[@]} -eq 0 ]]; then + requested=("${stages[@]}") +fi + +fmt() { + echo "==> fmt (format check)" + cargo fmt --all -- --check +} + +clippy() { + echo "==> clippy (all targets, warnings denied)" + cargo clippy --all-targets -- -D warnings +} + +check() { + echo "==> check" + cargo check +} + +test() { + echo "==> test" + cargo test +} + +no_default() { + echo "==> check core only (no default features: samurai plugin, mcp" + echo " extension, blocks pack and effects are all excluded)" + cargo check --no-default-features +} + +for stage in "${requested[@]}"; do + case "$stage" in + fmt) fmt ;; + clippy) clippy ;; + check) check ;; + test) test ;; + no-default) no_default ;; + *) + echo "[ci] unknown stage: $stage (expected one of: ${stages[*]})" >&2 + exit 1 + ;; + esac +done + +echo "[ci] all stages passed." diff --git a/src/commands/layout.rs b/src/commands/layout.rs new file mode 100644 index 0000000..5115e9d --- /dev/null +++ b/src/commands/layout.rs @@ -0,0 +1,125 @@ +//! `xtop layout` subcommands: validate layout files and install community +//! layouts from `layouts/custom/` of the `xtop-cli/layouts` repo into the +//! user config dir. + +use std::fs; +use std::path::Path; + +const LAYOUTS_REPO: &str = "https://github.com/xtop-cli/layouts"; + +/// Handle `xtop layout ` from the parsed argument vector. +pub fn layout_command(args: &[String]) -> anyhow::Result<()> { + if args.len() < 3 { + eprintln!("Usage: xtop layout "); + return Ok(()); + } + match args[2].as_str() { + "install" => { + if args.len() < 4 { + eprintln!("Usage: xtop layout install "); + return Ok(()); + } + cmd_install(&args[3]) + } + "check" => { + if args.len() < 4 { + eprintln!("Usage: xtop layout check "); + return Ok(()); + } + cmd_check(Path::new(&args[3])) + } + other => { + eprintln!("Unknown layout subcommand: {other}"); + Ok(()) + } + } +} + +/// Validate a layout file against the schema. +fn cmd_check(path: &Path) -> anyhow::Result<()> { + let data = fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("cannot read {}: {e}", path.display()))?; + match xtop_layout::parse_layout_err(&data) { + Ok(def) => { + println!("OK {} -> layout \"{}\" is valid", path.display(), def.name); + Ok(()) + } + Err(e) => anyhow::bail!("INVALID {} -> {e}", path.display()), + } +} + +/// Install a community layout from the repo's `layouts/custom/` folder. +fn cmd_install(name: &str) -> anyhow::Result<()> { + let tmp = std::env::temp_dir().join("xtop-layout-install"); + let _ = fs::remove_dir_all(&tmp); + println!("Fetching community layouts from {LAYOUTS_REPO} ..."); + let status = std::process::Command::new("git") + .args([ + "clone", + "--depth", + "1", + "--filter=blob:none", + "--sparse", + LAYOUTS_REPO, + tmp.to_str().unwrap(), + ]) + .status() + .map_err(|e| anyhow::anyhow!("failed to run git: {e}"))?; + if !status.success() { + anyhow::bail!("git clone failed"); + } + let checkout = std::process::Command::new("git") + .args(["sparse-checkout", "set", "layouts/custom"]) + .current_dir(&tmp) + .status() + .map_err(|e| anyhow::anyhow!("failed to run git: {e}"))?; + if !checkout.success() { + anyhow::bail!("git sparse-checkout failed"); + } + + let custom_dir = tmp.join("layouts").join("custom"); + let needle = name.to_lowercase(); + let mut found: Option<(String, String)> = None; // (filename, content) + if let Ok(entries) = fs::read_dir(&custom_dir) { + for entry in entries.flatten() { + let path = entry.path(); + let ext = path.extension().and_then(|e| e.to_str()); + if !matches!(ext, Some("json") | Some("jsonc")) { + continue; + } + let content = fs::read_to_string(&path).unwrap_or_default(); + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_lowercase(); + let matches = stem == needle + || xtop_layout::parse_layout(&content) + .map(|d| d.name.to_lowercase() == needle) + .unwrap_or(false); + if matches { + found = Some((entry.file_name().to_string_lossy().to_string(), content)); + break; + } + } + } + let _ = fs::remove_dir_all(&tmp); + + let Some((file_name, content)) = found else { + anyhow::bail!("no community layout named '{name}' in layouts/custom/ of {LAYOUTS_REPO}"); + }; + + let target_dir = crate::config::config_dir().join("layouts"); + fs::create_dir_all(&target_dir)?; + let target = target_dir.join(&file_name); + if target.exists() { + anyhow::bail!( + "{} already exists (edit it in place instead)", + target.display() + ); + } + fs::write(&target, content)?; + println!("Installed '{name}' -> {}", target.display()); + println!("Cycle layouts with 'l' (or restart xtop) to use it."); + Ok(()) +} diff --git a/src/commands/mcp.rs b/src/commands/mcp.rs new file mode 100644 index 0000000..27bf692 --- /dev/null +++ b/src/commands/mcp.rs @@ -0,0 +1,42 @@ +//! MCP server command. +//! +//! Runs the `mcp` server provided by the `xtop-extension-mcp` extension over +//! a freshly initialized application state. The extension drives xtop +//! through the `xtop-extension-api` host contract (tick + plugin actions), +//! so the kernel only wires state into the extension context. +//! +//! Requires both the `plugin-samurai` (tools execute against it) and the +//! `mcp-extension` features. + +#![cfg_attr( + not(all(feature = "plugin-samurai", feature = "mcp-extension")), + allow(dead_code, unused_imports) +)] + +use super::share::bootstrap::initialize_state; +use crate::config; + +#[cfg(all(feature = "plugin-samurai", feature = "mcp-extension"))] +use xtop_extension_api::Extension; + +/// Run the MCP server. +pub fn run_mcp_server() -> anyhow::Result<()> { + #[cfg(all(feature = "plugin-samurai", feature = "mcp-extension"))] + { + let cfg_dir = config::config_dir(); + let mut state = initialize_state(&cfg_dir)?; + + let mut extension = xtop_extension_mcp::McpExtension::new(); + let mut ctx = xtop_extension_api::ExtensionContext::new(&mut state); + extension + .run_server("mcp", &mut ctx) + .map_err(|e| anyhow::anyhow!("mcp server error: {e}")) + } + + #[cfg(not(all(feature = "plugin-samurai", feature = "mcp-extension")))] + { + eprintln!("MCP server requires the 'plugin-samurai' and 'mcp-extension' features."); + eprintln!("Rebuild with: cargo build --features plugin-samurai,mcp-extension"); + std::process::exit(1); + } +} diff --git a/src/commands/mod.rs b/src/commands/mod.rs new file mode 100644 index 0000000..dc9987b --- /dev/null +++ b/src/commands/mod.rs @@ -0,0 +1,11 @@ +//! CLI commands: interactive run, MCP server and plugin management. +//! +//! Shared assembly and asset helpers live under [`share`]. + +pub mod layout; +pub mod mcp; +pub mod plugins; +pub mod run; +pub(crate) mod share; + +pub(crate) use share::*; diff --git a/src/commands/plugins/install.rs b/src/commands/plugins/install.rs new file mode 100644 index 0000000..aa9d9fe --- /dev/null +++ b/src/commands/plugins/install.rs @@ -0,0 +1,150 @@ +//! `xtop plugin install`: register a plugin from a repo as an optional git +//! dependency + feature flag in the kernel `Cargo.toml`. + +use std::fs; +use std::path::{Path, PathBuf}; + +use super::{repo_root, LOCAL_PLUGINS_MARKER, PLUGINS_REPO}; + +pub(crate) fn cmd_plugin_install(name_or_url: &str) -> anyhow::Result<()> { + let tmp = std::env::temp_dir().join("xtop-plugin-install"); + + // Resolve the plugin package and its source repo. + let (pkg_name, source_repo) = if is_git_url(name_or_url) { + let repo = name_or_url; + clone_into_tmp(repo, &tmp)?; + let stem = Path::new(repo) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("plugin") + .trim_start_matches("xtop-plugin-"); + let (_, pkg) = find_plugin_crate(&tmp, stem)?; + (pkg, repo) + } else { + let name = name_or_url.trim().trim_start_matches("xtop-plugin-"); + clone_into_tmp(PLUGINS_REPO, &tmp)?; + if !tmp + .join("plugins") + .join(format!("xtop-plugin-{name}")) + .is_dir() + { + let _ = fs::remove_dir_all(&tmp); + anyhow::bail!( + "Plugin '{name}' not found in {PLUGINS_REPO}. \ + Available plugins live under plugins/xtop-plugin-." + ); + } + (format!("xtop-plugin-{name}"), PLUGINS_REPO) + }; + let feat_name = pkg_name.replace('-', "_"); + + // Register a git dependency + feature flag in the kernel Cargo.toml. + let manifest_path = repo_root().join("Cargo.toml"); + let content = fs::read_to_string(&manifest_path)?; + let dep_line = format!("{pkg_name} = {{ git = \"{source_repo}\", optional = true }}"); + let feature_line = format!("{feat_name} = [\"dep:{pkg_name}\"]"); + let mut new_content = content.clone(); + + if content + .lines() + .any(|l| l.trim().starts_with(&format!("{pkg_name} ="))) + { + println!("{pkg_name} is already a dependency of the kernel."); + } else { + if !content.contains(LOCAL_PLUGINS_MARKER) { + new_content = new_content.replace( + "[dependencies]", + &format!("[dependencies]\n{LOCAL_PLUGINS_MARKER}"), + ); + } + new_content = new_content.replace( + LOCAL_PLUGINS_MARKER, + &format!("{LOCAL_PLUGINS_MARKER}\n{dep_line}"), + ); + } + if !content.contains(&feature_line) { + new_content = new_content.replacen("[features]", &format!("[features]\n{feature_line}"), 1); + } + fs::write(&manifest_path, new_content)?; + + // Cleanup and verify the manifest resolves. + let _ = fs::remove_dir_all(&tmp); + println!("Verifying with `cargo check` ..."); + let status = std::process::Command::new("cargo") + .args(["check"]) + .current_dir(repo_root()) + .status() + .map_err(|e| anyhow::anyhow!("cargo check failed: {e}"))?; + if !status.success() { + anyhow::bail!("`cargo check` failed; check the plugin's compatibility."); + } + + println!(); + println!("Plugin '{pkg_name}' installed successfully."); + println!(" Source: {source_repo}"); + println!(" Feature flag: {feat_name} (NOT enabled by default)"); + println!(" Enable it: add '{feat_name}' to the [features] default list"); + println!(" in {manifest_path:?} and rebuild."); + Ok(()) +} + +fn is_git_url(s: &str) -> bool { + s.contains("://") || s.contains("github.com") || s.contains("git@") +} + +fn clone_into_tmp(repo_url: &str, tmp: &Path) -> anyhow::Result<()> { + let _ = fs::remove_dir_all(tmp); + println!("Cloning {repo_url} ..."); + let status = std::process::Command::new("git") + .args([ + "clone", + "--depth", + "1", + "--filter=blob:none", + "--sparse", + repo_url, + tmp.to_str().unwrap(), + ]) + .status() + .map_err(|e| anyhow::anyhow!("Failed to run git: {e}"))?; + if !status.success() { + anyhow::bail!("git clone failed"); + } + Ok(()) +} + +/// Resolve where the plugin crate lives inside a cloned repo and its package +/// name. Accepts: crate at repo root, or `plugins/xtop-plugin-`, +/// `plugins/` or `crates/` subdirectories. +fn find_plugin_crate(tmp: &Path, name: &str) -> anyhow::Result<(PathBuf, String)> { + let candidates = [ + tmp.join("plugins").join(format!("xtop-plugin-{name}")), + tmp.join("plugins").join(name), + tmp.join("crates").join(format!("xtop-plugin-{name}")), + tmp.join("crates").join(name), + tmp.to_path_buf(), // root crate last + ]; + for cand in &candidates { + let manifest = cand.join("Cargo.toml"); + if !manifest.is_file() { + continue; + } + let content = fs::read_to_string(&manifest).unwrap_or_default(); + if let Ok(toml) = content.parse::() { + if let Some(pkg) = toml.get("package").and_then(|p| p.get("name")) { + if let Some(pkg_name) = pkg.as_str() { + if pkg_name.starts_with("xtop-plugin-") { + println!("Found plugin at {}", cand.display()); + return Ok((cand.to_path_buf(), pkg_name.to_string())); + } + } + } + } + } + let _ = fs::remove_dir_all(tmp); + anyhow::bail!( + "No `xtop-plugin-*` package found for '{name}' in {}. \ + URL installs need the crate at the repo root or in plugins//crates/.", + tmp.display() + ) +} diff --git a/src/commands/plugins/list.rs b/src/commands/plugins/list.rs new file mode 100644 index 0000000..a755e0b --- /dev/null +++ b/src/commands/plugins/list.rs @@ -0,0 +1,46 @@ +//! `xtop plugin list`: show which plugins are wired into the kernel. + +use std::fs; + +use super::repo_root; + +pub(crate) fn cmd_plugin_list() { + let manifest = fs::read_to_string(repo_root().join("Cargo.toml")) + .expect("kernel Cargo.toml should exist next to the binary"); + + // A plugin counts as "installed" when it has a matching `dep:` + // feature entry (like the built-in xtop-plugin-samurai). Contract crates + // (xtop-plugin-api) are excluded. + let in_features = manifest + .lines() + .skip_while(|l| !l.trim().starts_with("[features]")) + .any(|l| l.contains("dep:")); + if !in_features { + println!("No plugins installed."); + return; + } + + let mut plugins: Vec = Vec::new(); + for line in manifest.lines() { + let l = line.trim(); + if !l.starts_with("xtop-plugin-") || !l.contains('=') || l.starts_with('#') { + continue; + } + let name = l.split('=').next().unwrap_or("").trim().to_string(); + if !name.is_empty() { + let dep_ref = format!("dep:{name}"); + if manifest.lines().any(|f| f.contains(&dep_ref)) { + plugins.push(name); + } + } + } + + if plugins.is_empty() { + println!("No plugins installed."); + return; + } + println!("Plugins wired into the kernel (Cargo.toml):"); + for p in plugins { + println!(" {p}"); + } +} diff --git a/src/commands/plugins/mod.rs b/src/commands/plugins/mod.rs new file mode 100644 index 0000000..2eb2ee7 --- /dev/null +++ b/src/commands/plugins/mod.rs @@ -0,0 +1,52 @@ +//! Plugin management subcommands (list, install, scaffold). +//! +//! Since the kernel became a monocrate, plugins live in their own repos +//! (`xtop-cli/plugins`) and are integrated through Cargo git dependencies + +//! feature flags, exactly like the built-in `xtop-plugin-samurai`. These +//! commands edit the kernel's own `Cargo.toml` accordingly. + +use std::path::Path; + +mod install; +mod list; +mod scaffold; + +pub const PLUGINS_REPO: &str = "https://github.com/xtop-cli/plugins"; +pub(crate) const LOCAL_PLUGINS_MARKER: &str = "# Local plugin installs (xtop plugin install)"; + +/// Kernel repo root: `Cargo.toml` lives right at `CARGO_MANIFEST_DIR`. +pub(crate) fn repo_root() -> &'static Path { + Path::new(env!("CARGO_MANIFEST_DIR")) +} + +/// Handle `xtop plugin ` from the parsed argument vector. +pub fn plugin_command(args: &[String]) -> anyhow::Result<()> { + if args.len() < 3 { + eprintln!("Usage: xtop plugin "); + return Ok(()); + } + match args[2].as_str() { + "list" => { + list::cmd_plugin_list(); + Ok(()) + } + "install" => { + if args.len() < 4 { + eprintln!("Usage: xtop plugin install "); + return Ok(()); + } + install::cmd_plugin_install(&args[3]) + } + "scaffold" => { + if args.len() < 4 { + eprintln!("Usage: xtop plugin scaffold "); + return Ok(()); + } + scaffold::cmd_plugin_scaffold(&args[3]) + } + _ => { + eprintln!("Unknown plugin subcommand: {}", args[2]); + Ok(()) + } + } +} diff --git a/src/commands/plugins/scaffold.rs b/src/commands/plugins/scaffold.rs new file mode 100644 index 0000000..4df9c81 --- /dev/null +++ b/src/commands/plugins/scaffold.rs @@ -0,0 +1,82 @@ +//! `xtop plugin scaffold`: generate a fresh plugin crate template under +//! `plugins-dev/` (ignored by git), ready to push as its own repo. + +use std::fs; + +use super::repo_root; + +pub(crate) fn cmd_plugin_scaffold(name: &str) -> anyhow::Result<()> { + let plugin_dir = repo_root() + .join("plugins-dev") + .join(format!("xtop-plugin-{name}")); + if plugin_dir.exists() { + anyhow::bail!("Plugin crate already exists at {}", plugin_dir.display()); + } + let src_dir = plugin_dir.join("src"); + fs::create_dir_all(&src_dir)?; + + let cap: String = { + let mut chars = name.chars(); + match chars.next() { + None => String::new(), + Some(c) => c.to_uppercase().to_string() + chars.as_str(), + } + }; + + let cargo_toml = format!( + r#"[package] +name = "xtop-plugin-{name}" +version = "0.1.0" +edition = "2021" +license = "MIT" +description = "xtop plugin: {name}" + +[dependencies] +xtop-plugin-api = {{ git = "https://github.com/xtop-cli/api" }} +"# + ); + fs::write(plugin_dir.join("Cargo.toml"), &cargo_toml)?; + + let lib_rs = format!( + r#"//! {cap} plugin for xtop. +use xtop_plugin_api::{{Plugin, PluginCapability, PluginContext, PluginError, PluginManifest}}; + +pub struct {cap}Plugin; + +impl {cap}Plugin {{ + pub fn new() -> Self {{ + Self + }} +}} + +impl Plugin for {cap}Plugin {{ + fn manifest(&self) -> PluginManifest {{ + PluginManifest {{ + id: "{name}".to_string(), + name: "{cap}".to_string(), + version: "0.1.0".to_string(), + description: "xtop plugin: {name}".to_string(), + capabilities: vec![PluginCapability::ReadSystemInfo], + }} + }} + + fn on_tick(&mut self, _ctx: &mut PluginContext) -> Result<(), PluginError> {{ + Ok(()) + }} +}} +"#, + cap = cap, + name = name + ); + fs::write(src_dir.join("lib.rs"), &lib_rs)?; + + println!("Plugin scaffold created at {}", plugin_dir.display()); + println!("To integrate it into the kernel:"); + println!( + " 1. `xtop plugin install {}` (works for repos with the crate", + name + ); + println!(" at the root or under plugins//crates/)."); + println!(" 2. Implement the Plugin trait methods."); + Ok(()) +} diff --git a/src/commands/run.rs b/src/commands/run.rs new file mode 100644 index 0000000..8d84e13 --- /dev/null +++ b/src/commands/run.rs @@ -0,0 +1,246 @@ +//! The interactive TUI run loop. + +use std::time::{Duration, Instant}; + +use crate::config::config_dir; +use crate::config::keybinding::Action; +use crate::state::{InputMode, PalettePage}; +use crate::ui; +use crossterm::event::{ + self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEventKind, +}; + +use super::share::{initialize_state, save_config}; + +fn key_event_to_str(key: &KeyEvent) -> String { + let mut s = String::new(); + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + if ctrl { + s.push_str("ctrl+"); + } + if key.modifiers.contains(KeyModifiers::ALT) { + s.push_str("alt+"); + } + match key.code { + KeyCode::Char(c) => { + if ctrl { + s.push(c.to_ascii_lowercase()); + } else { + s.push(c); + } + } + KeyCode::Esc => s.push_str("escape"), + KeyCode::Enter => s.push_str("enter"), + KeyCode::Backspace => s.push_str("backspace"), + KeyCode::Tab => s.push_str("tab"), + KeyCode::Up => s.push_str("up"), + KeyCode::Down => s.push_str("down"), + KeyCode::Left => s.push_str("left"), + KeyCode::Right => s.push_str("right"), + KeyCode::Delete => s.push_str("delete"), + KeyCode::Home => s.push_str("home"), + KeyCode::End => s.push_str("end"), + KeyCode::PageUp => s.push_str("pageup"), + KeyCode::PageDown => s.push_str("pagedown"), + _ => return String::new(), + } + s +} + +/// Run the interactive TUI loop. +pub fn run() -> anyhow::Result<()> { + ui::install_panic_hook(); + let mut terminal = ui::init()?; + + let cfg_dir = config_dir(); + let mut state = initialize_state(&cfg_dir)?; + + // Frame effects read the persisted `effect` key once at startup (same + // config file `initialize_state` already loaded). The host is + // feature-agnostic: without `--features effects` it is an idle no-op. + let mut effect_host = + ui::effects::EffectHost::from_config(crate::config::load_config().effect.as_deref()); + + // Sample once before the first frame so the UI never shows an empty + // snapshot and every widget shares the same per-tick data. + state.on_tick(); + let mut last_tick = Instant::now(); + + loop { + let tick_rate = Duration::from_millis(state.update_interval_ms.max(100)); + terminal.draw(|f| { + ui::render(f, &state); + // Effects transform the fully rendered frame (after layout, + // before flush); see the `xtop-effect-api` host contract. + effect_host.apply(f.buffer_mut()); + })?; + + let timeout = tick_rate + .checked_sub(last_tick.elapsed()) + .unwrap_or_default(); + + if event::poll(timeout)? { + match event::read()? { + Event::Key(key) if key.kind == KeyEventKind::Press => { + handle_key(&mut state, key); + } + Event::Paste(text) => { + // Bracketed paste: insert as text in the current input. + match state.input_mode { + InputMode::Searching => { + for c in text.chars() { + state.search_push_char(c); + } + } + InputMode::CommandPalette => { + state.palette.query.push_str(&text); + state.palette_filter(); + } + InputMode::Normal => {} + } + } + Event::Mouse(mouse) + if state.input_mode == InputMode::Normal && !state.show_help => + { + // Mouse capture is enabled mainly for wheel scrolling of + // the process list in Normal mode. + match mouse.kind { + MouseEventKind::ScrollUp => state.process_select_prev(), + MouseEventKind::ScrollDown => state.process_select_next(), + _ => {} + } + } + // Resize triggers a redraw on the next loop iteration + // (ratatui re-measures inside `draw`). + _ => {} + } + } + + if last_tick.elapsed() >= tick_rate { + state.on_tick(); + last_tick = Instant::now(); + } + + if state.should_quit { + break; + } + } + + // Disable plugins on shutdown + let _ = state.with_plugin_manager_mut(|mgr, this| { + mgr.disable_all(this); + }); + + ui::restore()?; + Ok(()) +} + +fn handle_key(state: &mut crate::state::AppState, key: KeyEvent) { + let key_str = key_event_to_str(&key); + let ctrl_c = key_str == "ctrl+c"; + + match state.input_mode { + InputMode::Normal => { + if ctrl_c { + save_config(state); + state.quit(); + return; + } + // Direct Ctrl+P check (works regardless of keybinding config, important on macOS) + if key_str == "ctrl+p" { + state.open_palette(); + state.input_mode = InputMode::CommandPalette; + return; + } + // Give plugins a chance to consume keys only in Normal mode, so a + // plugin key handler can never eat typing inside search/palette. + let key_consumed = state + .with_plugin_manager_mut(|mgr, this| mgr.handle_key(this, &key_str)) + .unwrap_or(false); + if key_consumed { + return; + } + + if cfg!(debug_assertions) && !key_str.is_empty() { + eprintln!("[key] '{key_str}'"); + } + + if let Some(action) = state.keybindings.resolve(&key_str) { + match action { + Action::Quit => { + save_config(state); + state.quit(); + } + Action::Cancel if state.show_help => { + state.toggle_help(); + } + Action::OpenCommandPalette => { + state.open_palette(); + state.input_mode = InputMode::CommandPalette; + } + _ => { + state.execute_action(&action); + if action.persists() { + save_config(state); + } + } + } + } + } + InputMode::Searching => { + if ctrl_c || key.code == KeyCode::Esc { + state.search_query.clear(); + state.end_search(); + return; + } + match key.code { + KeyCode::Enter => { + state.end_search(); + } + KeyCode::Backspace => { + state.search_pop_char(); + } + KeyCode::Char(c) => { + state.search_push_char(c); + } + _ => {} + } + } + InputMode::CommandPalette => { + let is_main = state.palette.page == PalettePage::Main; + if ctrl_c || key.code == KeyCode::Esc { + state.close_palette(); + return; + } + match key.code { + KeyCode::Enter => { + if let Some(action) = state.palette_selected_action() { + state.execute_action(&action); + if action.persists() { + save_config(state); + } + } + } + KeyCode::Down => { + state.palette_select_next(); + } + KeyCode::Up => { + state.palette_select_prev(); + } + KeyCode::Char(c) => { + state.palette.query.push(c); + state.palette_filter(); + } + KeyCode::Backspace => { + if state.palette.query.is_empty() && !is_main { + state.palette_navigate_to(PalettePage::Main); + } else { + state.palette.query.pop(); + state.palette_filter(); + } + } + _ => {} + } + } + } +} diff --git a/src/commands/share/assets.rs b/src/commands/share/assets.rs new file mode 100644 index 0000000..c610ec5 --- /dev/null +++ b/src/commands/share/assets.rs @@ -0,0 +1,139 @@ +//! Asset bootstrapping and config persistence used by several commands. + +use std::fs; + +use crate::config; +use crate::state::AppState; + +const DEFAULT_THEMES: &[(&str, &str)] = &[ + ("x", include_str!("../../../assets/themes/x.jsonc")), + ( + "madrid", + include_str!("../../../assets/themes/madrid.jsonc"), + ), + ( + "lahabana", + include_str!("../../../assets/themes/lahabana.jsonc"), + ), + ("paris", include_str!("../../../assets/themes/paris.jsonc")), + ("tokio", include_str!("../../../assets/themes/tokio.jsonc")), + ("oslo", include_str!("../../../assets/themes/oslo.jsonc")), + ( + "helsinki", + include_str!("../../../assets/themes/helsinki.jsonc"), + ), + ( + "berlin", + include_str!("../../../assets/themes/berlin.jsonc"), + ), + ( + "london", + include_str!("../../../assets/themes/london.jsonc"), + ), + ("praha", include_str!("../../../assets/themes/praha.jsonc")), + ( + "bogota", + include_str!("../../../assets/themes/bogota.jsonc"), + ), + ("miami", include_str!("../../../assets/themes/miami.jsonc")), +]; + +/// Version of the seeded asset templates. Bumped when the shipped defaults +/// change so existing installs receive the new templates (without ever +/// clobbering files the user has edited). "2": the `miami` theme joined the +/// embedded seeding set. +const ASSETS_VERSION: &str = "2"; + +/// Write shipped defaults (themes and layouts) into the user config dir. +/// +/// Files are only created when missing: user edits are never clobbered. The +/// defaults are the examples users copy to customize; the actual fallbacks +/// always live compiled into the binary/crates. +pub fn ensure_default_assets() { + let dir = crate::theme::themes_dir(); + seed_assets(&dir, DEFAULT_THEMES); + let dir = crate::config::config_dir().join("layouts"); + seed_assets(&dir, xtop_layout::default_layout_sources); +} + +fn seed_assets(dir: &std::path::Path, sources: &[(&str, &str)]) { + let marker = dir.join(".xtop_initialized"); + let up_to_date = fs::read_to_string(&marker) + .map(|v| v.trim() == ASSETS_VERSION) + .unwrap_or(false); + if up_to_date { + return; + } + fs::create_dir_all(dir).ok(); + for (name, content) in sources { + let path = dir.join(format!("{name}.jsonc")); + if !path.exists() { + fs::write(&path, content).ok(); + } + } + fs::write(marker, ASSETS_VERSION).ok(); +} + +/// Persist the runtime state into the user config file. +/// +/// Existing user values (`history_points`, update interval when untouched, +/// keybindings, alerts) are preserved: only the fields the runtime owns are +/// overwritten. +pub fn save_config(state: &AppState) { + let mut cfg = config::load_config(); + cfg.theme = state.current_theme.name.clone(); + if let Some(def) = state.layout_defs.get(state.layout_index) { + cfg.layout_name = def.name.clone(); + } + cfg.layout_mode = state.layout_mode; + cfg.update_interval_ms = state.update_interval_ms; + cfg.alerts = state.alerts.clone(); + cfg.keybindings = state.keybindings.clone(); + let _ = config::save_config(&cfg); +} + +#[cfg(test)] +mod tests { + use super::DEFAULT_THEMES; + + /// Every shipped theme file is embedded as a seeding template; the docs + /// (README, features, customization) count exactly 12 themes and the + /// assets/ folder must match `DEFAULT_THEMES` name for name. + #[test] + fn every_theme_file_is_embedded_as_a_seed() { + let names: Vec<&str> = DEFAULT_THEMES.iter().map(|(name, _)| *name).collect(); + assert_eq!(names.len(), 12, "docs claim 12 themes"); + for shipped in [ + "x", "berlin", "bogota", "helsinki", "lahabana", "london", "madrid", "miami", "oslo", + "paris", "praha", "tokio", + ] { + assert!( + names.contains(&shipped), + "missing embedded theme: {shipped}" + ); + } + } + + /// The embedded templates must parse through the same JSONC path the + /// loader uses at runtime (name + 16-entry palette), so a first run can + /// never seed a broken file. + #[test] + fn embedded_themes_parse_with_a_full_palette() { + for &(name, content) in DEFAULT_THEMES { + let cleaned = crate::theme::strip_jsonc_comments(content); + let parsed: serde_json::Value = serde_json::from_str(&cleaned) + .unwrap_or_else(|e| panic!("{name} template must be valid JSONC: {e}")); + assert_eq!( + parsed["name"].as_str(), + Some(name), + "theme template name must match its entry" + ); + let palette = parsed["palette"].as_array().expect("palette array"); + assert_eq!( + palette.len(), + 16, + "{name} palette must have exactly 16 colors" + ); + } + } +} diff --git a/src/commands/share/bootstrap.rs b/src/commands/share/bootstrap.rs new file mode 100644 index 0000000..4c2ac34 --- /dev/null +++ b/src/commands/share/bootstrap.rs @@ -0,0 +1,66 @@ +//! Bootstrap: assemble the live application state for a command run. + +use std::fs; +use std::path::Path; + +use crate::config; +use crate::plugins::PluginManager; +use crate::providers::sysinfo::SysinfoProvider; +use crate::providers::CompositeProvider; +use crate::state::AppState; +use crate::theme::load_all_themes; +use xtop_layout::{default_layouts, load_layouts_from_dir, merge_layouts}; + +#[cfg(feature = "plugin-samurai")] +use xtop_plugin_samurai::SamuraiPlugin; + +pub(crate) fn build_plugin_manager(state: &mut AppState, cfg_dir: &Path) -> PluginManager { + let plugins_dir = cfg_dir.join("plugins"); + fs::create_dir_all(&plugins_dir).ok(); + let mut mgr = PluginManager::new(plugins_dir); + + // Register plugins behind feature flags. + register_plugins(&mut mgr, state); + + mgr +} + +/// Register the optional compile-time plugins. +#[cfg(feature = "plugin-samurai")] +pub(crate) fn register_plugins(mgr: &mut PluginManager, state: &mut AppState) { + let plugin = Box::new(SamuraiPlugin::new()); + if let Err(e) = mgr.register(plugin, state) { + eprintln!("[xtop] failed to load samurai plugin: {e}"); + } +} + +/// No plugins selected at compile time. +#[cfg(not(feature = "plugin-samurai"))] +fn register_plugins(_mgr: &mut PluginManager, _state: &mut AppState) {} + +// --------------------------------------------------------------------------- +// CLI subcommands +// --------------------------------------------------------------------------- + +/// Assemble a fully initialized `AppState` for a command run. +pub fn initialize_state(cfg_dir: &Path) -> anyhow::Result { + let sysinfo_provider = SysinfoProvider::new(); + let composite = CompositeProvider::new(Box::new(sysinfo_provider)); + + let themes = load_all_themes(); + let cfg = config::load_config(); + + // Layouts: embedded defaults first; user files from the config dir then + // override defaults by name (user wins) — see `xtop_layout::merge_layouts`. + let layouts_dir = config::config_dir().join("layouts"); + let layout_defs = merge_layouts(default_layouts(), load_layouts_from_dir(&layouts_dir)); + + let mut state = AppState::new(Box::new(composite), themes, cfg, layout_defs); + + // Build and register plugins, then wire their providers into the state. + let plugin_mgr = build_plugin_manager(&mut state, cfg_dir); + let extra_providers = plugin_mgr.collect_data_providers(); + state.init_plugins(plugin_mgr, extra_providers); + + Ok(state) +} diff --git a/src/commands/share/mod.rs b/src/commands/share/mod.rs new file mode 100644 index 0000000..227ac6c --- /dev/null +++ b/src/commands/share/mod.rs @@ -0,0 +1,7 @@ +//! Shared helpers across CLI commands. + +pub(crate) mod assets; +pub(crate) mod bootstrap; + +pub(crate) use assets::*; +pub(crate) use bootstrap::*; diff --git a/crates/xtop-core/src/infrastructure/config.rs b/src/config/io.rs similarity index 54% rename from crates/xtop-core/src/infrastructure/config.rs rename to src/config/io.rs index 5eaf243..1793508 100644 --- a/crates/xtop-core/src/infrastructure/config.rs +++ b/src/config/io.rs @@ -1,31 +1,31 @@ -use crate::application::state::Config; +//! Config file read/write against the persisted schema. + use std::fs; use std::path::PathBuf; -pub fn config_dir() -> PathBuf { - if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { - PathBuf::from(xdg).join("xtop") - } else if let Ok(home) = std::env::var("HOME") { - PathBuf::from(home).join(".config").join("xtop") - } else { - PathBuf::from(".").join(".config").join("xtop") - } -} +use super::schema::Config; +use crate::config::config_dir; +/// Path of the persisted config file. pub fn config_path() -> PathBuf { config_dir().join("config.json") } +/// Load the config, falling back to defaults when missing or invalid. pub fn load_config() -> Config { let path = config_path(); if let Ok(data) = fs::read_to_string(&path) { - if let Ok(cfg) = serde_json::from_str::(&data) { + if let Ok(mut cfg) = serde_json::from_str::(&data) { + // Guard against a busy-looping event loop (interval 0 = spin at + // 100% CPU sampling the system every frame). + cfg.update_interval_ms = cfg.update_interval_ms.clamp(100, 3_600_000); return cfg; } } Config::default() } +/// Save the config to disk. pub fn save_config(config: &Config) -> Result<(), String> { let path = config_path(); if let Some(parent) = path.parent() { diff --git a/crates/xtop-core/src/domain/keybinding.rs b/src/config/keybinding.rs similarity index 89% rename from crates/xtop-core/src/domain/keybinding.rs rename to src/config/keybinding.rs index d2a4866..cd6d921 100644 --- a/crates/xtop-core/src/domain/keybinding.rs +++ b/src/config/keybinding.rs @@ -1,3 +1,4 @@ +//! Keybinding model: config-driven key -> Action resolution. use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -116,10 +117,24 @@ pub enum Action { KillProcess, ProcessUp, ProcessDown, - SortByPid, - SortByName, SortByCpu, - SortByMem, +} + +impl Action { + /// Whether executing this action changes persisted config (theme/layout). + /// Used to avoid rewriting `config.json` on every palette Enter. + pub fn persists(&self) -> bool { + matches!( + self, + Action::Quit + | Action::NextTheme + | Action::PreviousTheme + | Action::RandomTheme + | Action::NextLayout + | Action::SelectTheme(_) + | Action::SelectLayout(_) + ) + } } impl Keybindings { diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..083b88d --- /dev/null +++ b/src/config/mod.rs @@ -0,0 +1,11 @@ +//! Configuration area: file persistence, schema, keybindings and platform +//! dirs. + +mod io; +pub mod keybinding; +mod platform; +mod schema; + +pub use io::{load_config, save_config}; +pub use platform::config_dir; +pub use schema::*; diff --git a/src/config/platform/linux.rs b/src/config/platform/linux.rs new file mode 100644 index 0000000..fe4fef4 --- /dev/null +++ b/src/config/platform/linux.rs @@ -0,0 +1,16 @@ +//! Linux config dir: `$XDG_CONFIG_HOME/xtop`, falling back to +//! `$HOME/.config/xtop`. + +use std::path::PathBuf; + +use super::shared::env_path; + +pub fn config_dir() -> PathBuf { + env_path(&["XDG_CONFIG_HOME"]) + .unwrap_or_else(|| { + env_path(&["HOME"]) + .unwrap_or_else(|| PathBuf::from(".")) + .join(".config") + }) + .join("xtop") +} diff --git a/src/config/platform/macos.rs b/src/config/platform/macos.rs new file mode 100644 index 0000000..45de87b --- /dev/null +++ b/src/config/platform/macos.rs @@ -0,0 +1,13 @@ +//! macOS config dir: `$HOME/Library/Application Support/xtop`. + +use std::path::PathBuf; + +use super::shared::env_path; + +pub fn config_dir() -> PathBuf { + env_path(&["HOME"]) + .unwrap_or_default() + .join("Library") + .join("Application Support") + .join("xtop") +} diff --git a/src/config/platform/mod.rs b/src/config/platform/mod.rs new file mode 100644 index 0000000..434d2af --- /dev/null +++ b/src/config/platform/mod.rs @@ -0,0 +1,24 @@ +//! Config directory resolution per platform. +//! +//! Only here lives OS-specific path logic. Shared helpers used by every +//! platform live under [`shared`]. + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +mod other; +pub mod shared; + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(target_os = "linux")] +pub use linux::config_dir; +#[cfg(target_os = "macos")] +pub use macos::config_dir; +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +pub use other::config_dir; +#[cfg(target_os = "windows")] +pub use windows::config_dir; diff --git a/src/config/platform/other.rs b/src/config/platform/other.rs new file mode 100644 index 0000000..7fcc61e --- /dev/null +++ b/src/config/platform/other.rs @@ -0,0 +1,12 @@ +//! Fallback config dir for other Unix-likes: `$HOME/.config/xtop`. + +use std::path::PathBuf; + +use super::shared::env_path; + +pub fn config_dir() -> PathBuf { + env_path(&["HOME"]) + .unwrap_or_default() + .join(".config") + .join("xtop") +} diff --git a/src/config/platform/shared/mod.rs b/src/config/platform/shared/mod.rs new file mode 100644 index 0000000..74ae275 --- /dev/null +++ b/src/config/platform/shared/mod.rs @@ -0,0 +1,10 @@ +//! Helpers shared by all platform config-dir implementations. + +use std::path::PathBuf; + +/// First environment variable from `names` that resolves to a path. +pub(crate) fn env_path(names: &[&str]) -> Option { + names + .iter() + .find_map(|n| std::env::var_os(n).map(PathBuf::from)) +} diff --git a/src/config/platform/windows.rs b/src/config/platform/windows.rs new file mode 100644 index 0000000..faaf8fc --- /dev/null +++ b/src/config/platform/windows.rs @@ -0,0 +1,9 @@ +//! Windows config dir: `%APPDATA%\xtop`. + +use std::path::PathBuf; + +use super::shared::env_path; + +pub fn config_dir() -> PathBuf { + env_path(&["APPDATA"]).unwrap_or_default().join("xtop") +} diff --git a/src/config/schema.rs b/src/config/schema.rs new file mode 100644 index 0000000..ec78392 --- /dev/null +++ b/src/config/schema.rs @@ -0,0 +1,124 @@ +//! Persisted configuration schema. +//! +//! The on-disk config of the app. Theme and layout names are plain strings +//! here; the schema types stay independent of the runtime state. + +use crate::config::keybinding::Keybindings; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use xtop_layout::LayoutMode; +// Glyph style enums are shared ecosystem-wide (kernel + widget packs). +pub use xtop_widget_api::{ChartCharset, WidgetBorders}; +// Alert thresholds are contract data (DR-1): the plugin-api type is serde +// compatible under the exact keys this config used before (`cpu_high`, +// `mem_high`, `disk_high`), so the persisted JSON stays byte-compatible. +pub use xtop_plugin_api::AlertThresholds; + +// --------------------------------------------------------------------------- +// Widget glyph style +// --------------------------------------------------------------------------- + +/// Per-widget style overrides (key = widget name as used in layouts). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct WidgetStyle { + pub charset: Option, + pub borders: Option, + /// Widget pack to render this name with (e.g. "default", "blocks"). + pub pack: Option, +} + +/// Global glyph style for widgets. Drives chart markers and block borders so +/// users can pick line/block/ascii rendering without touching code. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct UiStyle { + pub charset: ChartCharset, + pub borders: WidgetBorders, + pub widgets: HashMap, + /// Widget pack used for every name without a per-widget override. + pub pack: Option, +} + +impl UiStyle { + /// Resolved charset for a widget (per-widget override beats global). + pub fn charset_for(&self, widget: &str) -> ChartCharset { + self.widgets + .get(widget) + .and_then(|w| w.charset) + .unwrap_or(self.charset) + } + + /// Resolved border style for a widget (per-widget override beats global). + pub fn borders_for(&self, widget: &str) -> WidgetBorders { + self.widgets + .get(widget) + .and_then(|w| w.borders) + .unwrap_or(self.borders) + } + + /// Resolved widget pack for a name (per-widget override beats global). + pub fn pack_for(&self, widget: &str) -> Option<&str> { + self.widgets + .get(widget) + .and_then(|w| w.pack.as_deref()) + .or(self.pack.as_deref()) + } +} + +fn default_layout_mode() -> LayoutMode { + LayoutMode::Dashboard +} + +/// Default alert thresholds (90/90/90), mirroring the pre-contract values. +/// The contract type itself carries no `Default`: config-level defaults are a +/// kernel concern and stay here, next to the persisted schema. +pub fn default_alerts() -> AlertThresholds { + AlertThresholds { + cpu_high: 90.0, + mem_high: 90.0, + disk_high: 90.0, + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Config { + pub theme: String, + #[serde(default = "default_layout_mode")] + pub layout_mode: LayoutMode, + /// Layout name for custom layouts beyond the 7 built-in LayoutMode variants. + /// If non-empty, takes precedence over `layout_mode`. + #[serde(default)] + pub layout_name: String, + pub update_interval_ms: u64, + pub history_points: usize, + #[serde(default = "default_alerts")] + pub alerts: AlertThresholds, + #[serde(default)] + pub keybindings: Keybindings, + /// Widget glyph style (chart charset + borders). Optional; defaults to + /// the classic look. + #[serde(default)] + pub style: UiStyle, + /// Frame effect id (e.g. "fade"), consumed by the optional `effects` + /// feature. Absent or unknown values disable effects; builds without the + /// feature ignore this key entirely. + #[serde(default)] + pub effect: Option, +} + +impl Default for Config { + fn default() -> Self { + Self { + theme: "x".to_string(), + layout_mode: LayoutMode::Dashboard, + layout_name: String::new(), + update_interval_ms: 1000, + history_points: 100, + alerts: default_alerts(), + keybindings: Keybindings::default(), + style: UiStyle::default(), + effect: None, + } + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..281077d --- /dev/null +++ b/src/main.rs @@ -0,0 +1,56 @@ +//! xtop CLI entry point. +//! +//! The binary is a thin dispatcher; the app is organized in areas: +//! `commands`, `config`, `plugins`, `providers`, `state`, `theme` and `ui`. +//! Layouts and widget packs live in their own repos and are consumed as +//! crates. + +mod commands; +mod config; +mod plugins; +mod providers; +mod state; +mod theme; +mod ui; + +use commands::{ensure_default_assets, mcp, run}; + +fn print_usage() { + eprintln!("Usage:"); + eprintln!(" xtop Start the TUI system monitor"); + eprintln!(" xtop mcp Start MCP server (stdio transport) for AI agents"); + eprintln!(" xtop plugin list List installed plugins"); + eprintln!(" xtop plugin install Install a plugin from github.com/xtop-cli/plugins"); + eprintln!(" xtop plugin install Install a plugin from a git URL"); + eprintln!(" xtop plugin scaffold Create a new plugin crate"); + eprintln!(" xtop layout check Validate a layout JSONC file"); + eprintln!(" xtop layout install Install a layout from github.com/xtop-cli/layouts"); +} + +fn main() -> anyhow::Result<()> { + let args: Vec = std::env::args().collect(); + + // CLI subcommands. + if args.len() > 1 { + match args[1].as_str() { + "mcp" => { + ensure_default_assets(); + return mcp::run_mcp_server(); + } + "plugin" => { + return commands::plugins::plugin_command(&args); + } + "layout" => { + return commands::layout::layout_command(&args); + } + "--help" | "-h" => { + print_usage(); + return Ok(()); + } + _ => {} + } + } + + ensure_default_assets(); + run::run() +} diff --git a/src/plugins/extension_host.rs b/src/plugins/extension_host.rs new file mode 100644 index 0000000..a473fe4 --- /dev/null +++ b/src/plugins/extension_host.rs @@ -0,0 +1,41 @@ +//! Kernel-side implementation of the extension host contract +//! (`xtop-extension-api`). +//! +//! Extensions drive the app by ticking it and executing named actions on +//! hosted plugins; both are delegated to the live [`AppState`]. + +use xtop_extension_api::{ExtensionError, ExtensionHost}; + +use crate::state::AppState; + +impl ExtensionHost for AppState { + fn tick(&mut self) { + AppState::on_tick(self); + } + + fn execute_plugin( + &mut self, + plugin_id: &str, + action: &str, + params: &str, + ) -> Result { + self.with_plugin_manager_mut(|mgr, this| { + mgr.execute(this, plugin_id, action, params) + .map_err(map_plugin_error) + }) + .unwrap_or(Err(ExtensionError::Recoverable( + "plugin manager not initialized".to_string(), + ))) + } +} + +fn map_plugin_error(e: xtop_plugin_api::PluginError) -> ExtensionError { + use xtop_plugin_api::PluginError; + match e { + PluginError::Recoverable(msg) => ExtensionError::Recoverable(msg), + PluginError::Fatal(msg) => ExtensionError::Fatal(msg), + PluginError::UnknownAction(action) => { + ExtensionError::Recoverable(format!("unknown action: {action}")) + } + } +} diff --git a/src/plugins/host.rs b/src/plugins/host.rs new file mode 100644 index 0000000..94215c2 --- /dev/null +++ b/src/plugins/host.rs @@ -0,0 +1,50 @@ +//! Kernel-side implementation of the plugin host contract (`xtop-plugin-api`). +//! +//! The live [`AppState`] is what plugins see through [`HostState`], so plugin +//! code never depends on kernel types. + +use crate::state::AppState; +use xtop_plugin_api::{AlertThresholds, HostState, RuntimeConfig, SystemInfo, SystemSnapshot}; + +impl HostState for AppState { + fn snapshot(&self) -> SystemSnapshot { + AppState::snapshot(self) + } + + fn system_info(&self) -> SystemInfo { + self.sys_info.clone() + } + + fn kill_process(&mut self, pid: u32) -> bool { + AppState::kill_process_by_pid(self, pid) + } + + fn set_alert_thresholds(&mut self, cpu: f64, mem: f64, disk: f64) { + AppState::set_alert_thresholds(self, cpu, mem, disk); + } + + fn alerts(&self) -> AlertThresholds { + self.alerts.clone() + } + + fn config(&self) -> RuntimeConfig { + RuntimeConfig { + theme: self.current_theme.name.clone(), + layout: self.current_layout_name().to_string(), + interval_ms: self.update_interval_ms, + hostname: self.sys_info.hostname.clone(), + } + } + + fn set_theme_by_name(&mut self, name: &str) -> bool { + AppState::set_theme_by_name(self, name) + } + + fn set_layout_by_name(&mut self, name: &str) -> bool { + AppState::set_layout_by_name(self, name) + } + + fn set_update_interval_ms(&mut self, ms: u64) { + self.update_interval_ms = ms; + } +} diff --git a/crates/xtop-core/src/application/plugin_manager.rs b/src/plugins/manager.rs similarity index 85% rename from crates/xtop-core/src/application/plugin_manager.rs rename to src/plugins/manager.rs index 5ffaf43..71267e4 100644 --- a/crates/xtop-core/src/application/plugin_manager.rs +++ b/src/plugins/manager.rs @@ -1,11 +1,10 @@ +//! Plugin host: lifecycle, tick/key dispatch and capability routing. use std::fmt::Debug; use std::path::PathBuf; -use crate::application::state::AppState; -use crate::domain::plugin::{ - Plugin, PluginCapability, PluginContext, PluginError, PluginManifest, WidgetRegistration, -}; -use crate::domain::system_info::SystemDataProvider; +use crate::state::AppState; +use xtop_plugin_api::SystemDataProvider; +use xtop_plugin_api::{Plugin, PluginCapability, PluginContext, PluginError, PluginWidget}; /// Manages the lifecycle of all loaded plugins. /// @@ -42,7 +41,8 @@ impl PluginManager { /// Register and enable a plugin. /// /// This calls `on_enable` on the plugin. If it fails, the plugin is not added - /// and the error is logged. + /// and the error is logged. Only reachable when a plugin is compiled in. + #[cfg_attr(not(feature = "plugin-samurai"), allow(dead_code))] pub fn register( &mut self, mut plugin: Box, @@ -55,11 +55,7 @@ impl PluginManager { })?; let capabilities = plugin.manifest().capabilities.clone(); - let mut ctx = PluginContext { - state, - plugin_data_dir: data_dir, - capabilities, - }; + let mut ctx = PluginContext::new(state, data_dir, capabilities); plugin.on_enable(&mut ctx)?; @@ -78,11 +74,7 @@ impl PluginManager { ) -> PluginContext<'a> { let id = plugin.manifest().id.clone(); let capabilities = plugin.manifest().capabilities.clone(); - PluginContext { - state, - plugin_data_dir: base.join(&id), - capabilities, - } + PluginContext::new(state, base.join(&id), capabilities) } /// Call `on_tick` on every enabled plugin. @@ -130,8 +122,8 @@ impl PluginManager { /// Collect all widget registrations from plugins. /// Only includes widgets from plugins with `RenderWidgets` capability. - pub fn collect_widgets(&self) -> Vec { - let mut widgets: Vec = Vec::new(); + pub fn collect_widgets(&self) -> Vec { + let mut widgets: Vec = Vec::new(); for plugin in &self.plugins { if Self::plugin_has_capability(&**plugin, &PluginCapability::RenderWidgets) { if let Some(widget) = plugin.widget() { @@ -165,16 +157,6 @@ impl PluginManager { ))) } - /// List all loaded plugin manifests (for display / status). - pub fn manifests(&self) -> Vec { - self.plugins.iter().map(|p| p.manifest()).collect() - } - - /// Number of loaded plugins. - pub fn count(&self) -> usize { - self.plugins.len() - } - /// Call `on_disable` on all plugins (e.g. on shutdown). pub fn disable_all(&mut self, state: &mut AppState) { let base = self.plugin_data_base.clone(); diff --git a/src/plugins/mod.rs b/src/plugins/mod.rs new file mode 100644 index 0000000..794d4f1 --- /dev/null +++ b/src/plugins/mod.rs @@ -0,0 +1,11 @@ +//! Plugin host area: everything the kernel does to drive plugins. +//! +//! `manager` is the host side of the plugin lifecycle; `host` bridges the +//! kernel `AppState` to the contract view `xtop_plugin_api::HostState`; +//! `extension_host` does the same for extensions. + +mod extension_host; +mod host; +mod manager; + +pub use manager::*; diff --git a/crates/xtop-core/src/infrastructure/composite_provider.rs b/src/providers/composite.rs similarity index 91% rename from crates/xtop-core/src/infrastructure/composite_provider.rs rename to src/providers/composite.rs index 4cd083e..e1adb90 100644 --- a/crates/xtop-core/src/infrastructure/composite_provider.rs +++ b/src/providers/composite.rs @@ -1,11 +1,12 @@ -use crate::domain::metrics::*; -use crate::domain::system_info::SystemDataProvider; +//! Composite data provider: kernel provider plus plugin providers. +use xtop_plugin_api::model::*; +use xtop_plugin_api::SystemDataProvider; /// A `SystemDataProvider` that composes a primary provider with plugin-provided extras. /// /// The primary provider (usually `SysinfoProvider`) handles `refresh_all()` and `snapshot()`. /// Extra providers (from plugins) override specific methods like `gpu_info()`, `batteries()`, -/// `docker_info()`, `disk_io()`, or `system_info()`. +/// `disk_io()`, or `system_info()`. /// /// This allows plugins to inject data without modifying the primary provider. pub struct CompositeProvider { @@ -66,10 +67,6 @@ impl SystemDataProvider for CompositeProvider { self.first_non_empty(|| self.primary.gpu_info(), |e| e.gpu_info()) } - fn docker_info(&self) -> Vec { - self.first_non_empty(|| self.primary.docker_info(), |e| e.docker_info()) - } - fn system_info(&self) -> SystemInfo { let primary = self.primary.system_info(); if !primary.hostname.is_empty() { diff --git a/src/providers/mod.rs b/src/providers/mod.rs new file mode 100644 index 0000000..f6c9d87 --- /dev/null +++ b/src/providers/mod.rs @@ -0,0 +1,10 @@ +//! Providers area: data sources that feed the state. +//! +//! `composite` merges several providers; `sysinfo` implements the main +//! cross-platform provider with per-OS probes under its `platform` tree. + +mod composite; + +pub mod sysinfo; + +pub use composite::*; diff --git a/src/providers/sysinfo/mod.rs b/src/providers/sysinfo/mod.rs new file mode 100644 index 0000000..ca6424b --- /dev/null +++ b/src/providers/sysinfo/mod.rs @@ -0,0 +1,12 @@ +//! System metrics provider for the `xtop` kernel. +//! +//! The real-time monitoring data comes from the `sysinfo` crate +//! (cross-platform) plus a small set of OS-specific probes defined under +//! [`platform`]. Platform modules must compile for every supported target and +//! fall back to empty/default values when the OS does not provide the data. + +pub mod platform; + +mod provider; + +pub use provider::SysinfoProvider; diff --git a/src/providers/sysinfo/platform/fallback.rs b/src/providers/sysinfo/platform/fallback.rs new file mode 100644 index 0000000..89a1bb0 --- /dev/null +++ b/src/providers/sysinfo/platform/fallback.rs @@ -0,0 +1,31 @@ +//! Fallback probes for any other Unix-like target (BSD, etc.). +//! +//! Keeps the provider compiling everywhere while only the sysinfo crate data +//! is available on those platforms. + +use std::collections::HashMap; +use xtop_plugin_api::model::{BatteryInfo, GpuInfo}; + +pub fn read_cpu_governor(_cpu_id: usize) -> String { + String::new() +} + +pub fn read_mount_options() -> HashMap { + HashMap::new() +} + +pub fn read_interface_ips() -> HashMap> { + HashMap::new() +} + +pub fn read_batteries() -> Vec { + Vec::new() +} + +pub fn read_thread_count(_pid: sysinfo::Pid) -> u64 { + 0 +} + +pub fn read_gpu_info_from_sysfs() -> Vec { + Vec::new() +} diff --git a/src/providers/sysinfo/platform/linux/battery.rs b/src/providers/sysinfo/platform/linux/battery.rs new file mode 100644 index 0000000..9e267af --- /dev/null +++ b/src/providers/sysinfo/platform/linux/battery.rs @@ -0,0 +1,85 @@ +//! Linux battery probes from `/sys/class/power_supply`. + +use std::fs; +use std::path::Path; + +use xtop_plugin_api::model::BatteryInfo; + +/// Battery state from `/sys/class/power_supply`. +pub fn read_batteries() -> Vec { + let mut batteries = Vec::new(); + let power_supply = Path::new("/sys/class/power_supply"); + if !power_supply.exists() { + return batteries; + } + if let Ok(entries) = fs::read_dir(power_supply) { + for entry in entries.flatten() { + let name = match entry.file_name().to_str() { + Some(n) if n.starts_with("BAT") => n.to_string(), + _ => continue, + }; + let base = entry.path(); + let capacity = fs::read_to_string(base.join("capacity")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0.0); + let state = fs::read_to_string(base.join("status")) + .ok() + .map(|s| s.trim().to_string()) + .unwrap_or_default(); + let charge_full = fs::read_to_string(base.join("charge_full")) + .ok() + .and_then(|s| s.trim().parse::().ok()); + let charge_now = fs::read_to_string(base.join("charge_now")) + .ok() + .and_then(|s| s.trim().parse::().ok()); + let cycles = fs::read_to_string(base.join("cycle_count")) + .ok() + .and_then(|s| s.trim().parse::().ok()); + let power_now = fs::read_to_string(base.join("power_now")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0); + let charge_full_design = fs::read_to_string(base.join("charge_full_design")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(1); + + let health = if charge_full_design > 0 { + (charge_full.unwrap_or(1) as f32 / charge_full_design as f32) * 100.0 + } else { + 100.0 + }; + + // Time to full/empty estimated from the current power draw. + let (time_to_full, time_to_empty) = if power_now != 0 && power_now.abs() > 0 { + if state == "Charging" { + let remaining = charge_full + .unwrap_or(0) + .saturating_sub(charge_now.unwrap_or(0)); + let secs = (remaining as f64 / power_now.abs() as f64 * 3600.0) as u64; + (Some(secs), None) + } else if state == "Discharging" { + let secs = + (charge_now.unwrap_or(0) as f64 / power_now.abs() as f64 * 3600.0) as u64; + (None, Some(secs)) + } else { + (None, None) + } + } else { + (None, None) + }; + + batteries.push(BatteryInfo { + name, + percentage: capacity, + state, + time_to_full, + time_to_empty, + health, + cycle_count: cycles, + }); + } + } + batteries +} diff --git a/src/providers/sysinfo/platform/linux/governor.rs b/src/providers/sysinfo/platform/linux/governor.rs new file mode 100644 index 0000000..cf0ad88 --- /dev/null +++ b/src/providers/sysinfo/platform/linux/governor.rs @@ -0,0 +1,12 @@ +//! Linux CPU governor probe from the `cpufreq` sysfs interface. + +use std::fs; + +/// CPU governor of a given core, empty when unavailable. +pub fn read_cpu_governor(cpu_id: usize) -> String { + fs::read_to_string(format!( + "/sys/devices/system/cpu/cpu{cpu_id}/cpufreq/scaling_governor" + )) + .map(|s| s.trim().to_string()) + .unwrap_or_default() +} diff --git a/src/providers/sysinfo/platform/linux/gpu.rs b/src/providers/sysinfo/platform/linux/gpu.rs new file mode 100644 index 0000000..9b45770 --- /dev/null +++ b/src/providers/sysinfo/platform/linux/gpu.rs @@ -0,0 +1,63 @@ +//! Linux GPU probe from `/sys/class/drm` (used when `nvidia-smi` is absent). + +use std::fs; +use std::path::Path; + +use xtop_plugin_api::model::GpuInfo; + +/// Extra GPU detection from `/sys/class/drm`. +pub fn read_gpu_info_from_sysfs() -> Vec { + let mut gpus = Vec::new(); + if let Ok(entries) = fs::read_dir("/sys/class/drm/") { + for entry in entries.flatten() { + let fname = entry.file_name().to_string_lossy().to_string(); + if fname.starts_with("card") && !fname.contains('-') { + let base = entry.path(); + let dev = base.join("device"); + let gpu_name = fs::read_to_string(dev.join("product_name")) + .ok() + .map(|s| s.trim().to_string()) + .unwrap_or_else(|| fname.clone()); + let mem_total = fs::read_to_string(dev.join("mem_info_vram_total")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0); + let mem_used = fs::read_to_string(dev.join("mem_info_vram_used")) + .ok() + .and_then(|s| s.trim().parse::().ok()) + .unwrap_or(0); + let temp = find_hwmon_temp(&base.join("device"), "gpu").unwrap_or(0.0); + gpus.push(GpuInfo { + name: gpu_name, + usage: 0.0, + temperature: temp, + memory_total: mem_total, + memory_used: mem_used, + }); + } + } + } + gpus +} + +/// First hwmon temperature (in celsius) whose label matches `label_filter`. +fn find_hwmon_temp(device_path: &Path, label_filter: &str) -> Option { + let hwmon = device_path.join("hwmon"); + if hwmon.exists() { + if let Ok(entries) = fs::read_dir(&hwmon) { + for entry in entries.flatten() { + let hwmon_dir = entry.path(); + if let Ok(labels) = fs::read_to_string(hwmon_dir.join("temp1_label")) { + if labels.trim().to_lowercase().contains(label_filter) { + if let Ok(input) = fs::read_to_string(hwmon_dir.join("temp1_input")) { + if let Ok(millideg) = input.trim().parse::() { + return Some(millideg / 1000.0); + } + } + } + } + } + } + } + None +} diff --git a/src/providers/sysinfo/platform/linux/interfaces.rs b/src/providers/sysinfo/platform/linux/interfaces.rs new file mode 100644 index 0000000..2b103ce --- /dev/null +++ b/src/providers/sysinfo/platform/linux/interfaces.rs @@ -0,0 +1,38 @@ +//! Linux network interface address probe from `/proc/net`. + +use std::collections::HashMap; +use std::fs; + +/// Interface name to IP addresses. +pub fn read_interface_ips() -> HashMap> { + let mut map: HashMap> = HashMap::new(); + // IPv6 from /proc/net/if_inet6. + if let Ok(content) = fs::read_to_string("/proc/net/if_inet6") { + for line in content.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 5 { + let addr_hex = parts[0]; + let iface = parts[4].to_string(); + if addr_hex.len() == 32 { + let ip: String = (0..8) + .map(|i| { + let start = i * 4; + let group = &addr_hex[start..start + 4]; + let trimmed = group.trim_start_matches('0'); + let val = u16::from_str_radix( + if trimmed.is_empty() { "0" } else { trimmed }, + 16, + ) + .unwrap_or(0); + format!("{:x}", val) + }) + .collect::>() + .join(":"); + map.entry(iface).or_default().push(ip); + } + } + } + } + // IPv4 could be added from /proc/net/fib_trie if ever needed. + map +} diff --git a/src/providers/sysinfo/platform/linux/mod.rs b/src/providers/sysinfo/platform/linux/mod.rs new file mode 100644 index 0000000..d58cba8 --- /dev/null +++ b/src/providers/sysinfo/platform/linux/mod.rs @@ -0,0 +1,18 @@ +//! Linux probes for the sysinfo area. +//! +//! Each subsystem lives in its own file and is re-exported here, so the +//! platform dispatcher only talks to this module. + +mod battery; +mod governor; +mod gpu; +mod interfaces; +mod mounts; +mod threads; + +pub use battery::*; +pub use governor::*; +pub use gpu::*; +pub use interfaces::*; +pub use mounts::*; +pub use threads::*; diff --git a/src/providers/sysinfo/platform/linux/mounts.rs b/src/providers/sysinfo/platform/linux/mounts.rs new file mode 100644 index 0000000..b09ee28 --- /dev/null +++ b/src/providers/sysinfo/platform/linux/mounts.rs @@ -0,0 +1,22 @@ +//! Linux mount option probe from `/proc/self/mountinfo`. + +use std::collections::HashMap; +use std::fs; + +/// Mount option string per mount point. +/// +/// Format per line: `id parent_id maj:min root mount_point options ...` +pub fn read_mount_options() -> HashMap { + let mut map = HashMap::new(); + if let Ok(content) = fs::read_to_string("/proc/self/mountinfo") { + for line in content.lines() { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 6 { + let mount_point = parts[4].to_string(); + let opts = parts[5].to_string(); + map.insert(mount_point, opts); + } + } + } + map +} diff --git a/src/providers/sysinfo/platform/linux/threads.rs b/src/providers/sysinfo/platform/linux/threads.rs new file mode 100644 index 0000000..c46d7dd --- /dev/null +++ b/src/providers/sysinfo/platform/linux/threads.rs @@ -0,0 +1,16 @@ +//! Linux process thread count probe from `/proc//status`. + +use std::fs; + +/// Thread count of a process. +pub fn read_thread_count(pid: sysinfo::Pid) -> u64 { + let path = format!("/proc/{pid}/status"); + if let Ok(content) = fs::read_to_string(&path) { + for line in content.lines() { + if let Some(rest) = line.strip_prefix("Threads:\t") { + return rest.trim().parse::().unwrap_or(0); + } + } + } + 0 +} diff --git a/src/providers/sysinfo/platform/macos.rs b/src/providers/sysinfo/platform/macos.rs new file mode 100644 index 0000000..8c50183 --- /dev/null +++ b/src/providers/sysinfo/platform/macos.rs @@ -0,0 +1,32 @@ +//! macOS probes. +//! +//! Real implementations would use IOKit for batteries and GPU, `getifaddrs` +//! for interface addresses and `getmntinfo` for mount options. Until then the +//! sysinfo crate is the only data source and these helpers stay empty. + +use std::collections::HashMap; +use xtop_plugin_api::model::{BatteryInfo, GpuInfo}; + +pub fn read_cpu_governor(_cpu_id: usize) -> String { + String::new() +} + +pub fn read_mount_options() -> HashMap { + HashMap::new() +} + +pub fn read_interface_ips() -> HashMap> { + HashMap::new() +} + +pub fn read_batteries() -> Vec { + Vec::new() +} + +pub fn read_thread_count(_pid: sysinfo::Pid) -> u64 { + 0 +} + +pub fn read_gpu_info_from_sysfs() -> Vec { + Vec::new() +} diff --git a/src/providers/sysinfo/platform/mod.rs b/src/providers/sysinfo/platform/mod.rs new file mode 100644 index 0000000..5c8716a --- /dev/null +++ b/src/providers/sysinfo/platform/mod.rs @@ -0,0 +1,33 @@ +//! OS-specific probes for the [`super`] sysinfo area. +//! +//! Every platform module below provides the same function set. The modules +//! that are not compiled for the current target are simply not exported; +//! `cfg` dispatch happens here in one place. +//! +//! [`shared`] holds probe logic used by more than one platform. +//! +//! Platform notes: +//! - **Linux**: reads `/sys` and `/proc` directly (governor, batteries, +//! interface addresses, threads, DRM GPUs). +//! - **macOS**: would use IOKit / getifaddrs / getmntinfo. +//! - **Windows**: would use WMI / GetAdaptersAddresses / NtQueryInformationProcess. + +pub mod shared; + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +mod fallback; +#[cfg(target_os = "linux")] +mod linux; +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "windows")] +mod windows; + +#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] +pub use fallback::*; +#[cfg(target_os = "linux")] +pub use linux::*; +#[cfg(target_os = "macos")] +pub use macos::*; +#[cfg(target_os = "windows")] +pub use windows::*; diff --git a/src/providers/sysinfo/platform/shared/gpu.rs b/src/providers/sysinfo/platform/shared/gpu.rs new file mode 100644 index 0000000..e0d7ee1 --- /dev/null +++ b/src/providers/sysinfo/platform/shared/gpu.rs @@ -0,0 +1,37 @@ +//! GPU probe via the `nvidia-smi` CLI, shared by Linux and Windows. + +use xtop_plugin_api::model::GpuInfo; + +/// Query NVIDIA GPUs through `nvidia-smi` when available. +pub fn read_gpu_info_nvidia_smi() -> Vec { + let mut gpus = Vec::new(); + if let Ok(output) = std::process::Command::new("nvidia-smi") + .args([ + "--query-gpu=name,utilization.gpu,memory.total,memory.used,temperature.gpu", + "--format=csv,noheader,nounits", + ]) + .output() + { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); + if parts.len() >= 5 { + let name = parts[0].to_string(); + let usage = parts[1].parse::().unwrap_or(0.0); + let mem_total = parts[2].parse::().unwrap_or(0) * 1024 * 1024; + let mem_used = parts[3].parse::().unwrap_or(0) * 1024 * 1024; + let temp = parts[4].parse::().unwrap_or(0.0); + gpus.push(GpuInfo { + name, + usage, + temperature: temp, + memory_total: mem_total, + memory_used: mem_used, + }); + } + } + } + } + gpus +} diff --git a/src/providers/sysinfo/platform/shared/mod.rs b/src/providers/sysinfo/platform/shared/mod.rs new file mode 100644 index 0000000..8e9bdb9 --- /dev/null +++ b/src/providers/sysinfo/platform/shared/mod.rs @@ -0,0 +1,5 @@ +//! Shared probes used by more than one platform. + +mod gpu; + +pub use gpu::*; diff --git a/src/providers/sysinfo/platform/windows.rs b/src/providers/sysinfo/platform/windows.rs new file mode 100644 index 0000000..358106d --- /dev/null +++ b/src/providers/sysinfo/platform/windows.rs @@ -0,0 +1,33 @@ +//! Windows probes. +//! +//! Real implementations would use WMI / PowerShell for batteries, +//! `GetAdaptersAddresses` for interface addresses and +//! `NtQueryInformationProcess` for thread counts. Until then the sysinfo +//! crate is the only data source and these helpers stay empty. + +use std::collections::HashMap; +use xtop_plugin_api::model::{BatteryInfo, GpuInfo}; + +pub fn read_cpu_governor(_cpu_id: usize) -> String { + String::new() +} + +pub fn read_mount_options() -> HashMap { + HashMap::new() +} + +pub fn read_interface_ips() -> HashMap> { + HashMap::new() +} + +pub fn read_batteries() -> Vec { + Vec::new() +} + +pub fn read_thread_count(_pid: sysinfo::Pid) -> u64 { + 0 +} + +pub fn read_gpu_info_from_sysfs() -> Vec { + Vec::new() +} diff --git a/crates/xtop-core/src/infrastructure/sysinfo_provider.rs b/src/providers/sysinfo/provider.rs similarity index 51% rename from crates/xtop-core/src/infrastructure/sysinfo_provider.rs rename to src/providers/sysinfo/provider.rs index bdcf373..edc66b8 100644 --- a/crates/xtop-core/src/infrastructure/sysinfo_provider.rs +++ b/src/providers/sysinfo/provider.rs @@ -1,11 +1,22 @@ -use crate::domain::metrics::*; -use crate::domain::system_info::SystemDataProvider; +//! Cross-platform implementation of [`SystemDataProvider`] for the kernel. +//! +//! Real-time data comes from the `sysinfo` crate; only the OS-specific gaps +//! (governors, batteries, interface IPs, thread counts, extra GPUs) are +//! delegated to [`super::platform`]. + +use super::platform::shared::read_gpu_info_nvidia_smi; +use super::platform::{ + read_batteries, read_cpu_governor, read_gpu_info_from_sysfs, read_interface_ips, + read_mount_options, read_thread_count, +}; use std::collections::HashMap; use std::time::Instant; use sysinfo::{ Components, CpuRefreshKind, Disks, MemoryRefreshKind, Networks, Pid, ProcessRefreshKind, RefreshKind, Signal, System, }; +use xtop_plugin_api::model::*; +use xtop_plugin_api::SystemDataProvider; pub const DEFAULT_MAX_PROCESSES: usize = 200; @@ -31,6 +42,13 @@ impl Default for SysinfoProvider { impl SysinfoProvider { pub fn new() -> Self { + // The snapshot is capped to the top-CPU processes to bound per-tick + // work; users can raise the cap via XTOP_MAX_PROCESSES. + let max_processes = std::env::var("XTOP_MAX_PROCESSES") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(DEFAULT_MAX_PROCESSES) + .max(1); let sys = System::new_with_specifics( RefreshKind::nothing() .with_cpu(CpuRefreshKind::everything()) @@ -59,7 +77,7 @@ impl SysinfoProvider { prev_net_tx: HashMap::new(), last_refresh: Instant::now(), cached_sys_info: info, - max_processes: DEFAULT_MAX_PROCESSES, + max_processes, } } } @@ -70,6 +88,20 @@ impl SystemDataProvider for SysinfoProvider { self.disks.refresh(true); self.networks.refresh(true); self.components.refresh(true); + + // Record rate baselines *after* this refresh: next sample computes + // bytes/s as the delta since this point over `last_refresh`. + for (name, n) in self.networks.iter() { + self.prev_net_rx.insert(name.clone(), n.received()); + self.prev_net_tx.insert(name.clone(), n.transmitted()); + } + for d in self.disks.iter() { + let usage = d.usage(); + let key = d.mount_point().to_string_lossy().to_string(); + self.prev_disk_read.insert(key.clone(), usage.read_bytes); + self.prev_disk_write.insert(key, usage.written_bytes); + } + self.last_refresh = Instant::now(); } fn snapshot(&self) -> SystemSnapshot { @@ -184,18 +216,23 @@ impl SystemDataProvider for SysinfoProvider { .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_secs(); + let uptime = System::uptime(); + // sysinfo reports process start relative to boot; expose it as epoch + // seconds so consumers (widgets, plugins) compare against one clock. + let boot_epoch = now.saturating_sub(uptime); let mut procs: Vec = self .sys .processes() .iter() .map(|(pid, p)| { - let start = p.start_time(); - let run = if start > 0 { - now.saturating_sub(start) + let start_raw = p.start_time(); + let start = if start_raw > 0 { + boot_epoch.saturating_add(start_raw) } else { 0 }; + let run = now.saturating_sub(start); ProcessInfo { pid: pid.as_u32(), name: p.name().to_string_lossy().to_string(), @@ -271,12 +308,11 @@ impl SystemDataProvider for SysinfoProvider { five: load.five, fifteen: load.fifteen, }, - uptime: System::uptime(), + uptime, disk_io: self.disk_io_inner(), batteries: read_batteries(), gpus: read_gpu_info(), - dockers: vec![], - sys_info: SystemInfo::default(), + sys_info: self.cached_sys_info.clone(), } } @@ -362,289 +398,15 @@ impl SysinfoProvider { } } -// --------------------------------------------------------------------------- -// Platform-specific helpers with graceful fallbacks -// --------------------------------------------------------------------------- - -#[cfg(target_os = "linux")] -fn read_cpu_governor(_cpu_id: usize) -> String { - std::fs::read_to_string(format!( - "/sys/devices/system/cpu/cpu{_cpu_id}/cpufreq/scaling_governor" - )) - .map(|s| s.trim().to_string()) - .unwrap_or_default() -} - -#[cfg(not(target_os = "linux"))] -fn read_cpu_governor(_cpu_id: usize) -> String { - String::new() -} - -#[cfg(target_os = "linux")] -fn read_mount_options() -> HashMap { - let mut map = HashMap::new(); - if let Ok(content) = std::fs::read_to_string("/proc/self/mountinfo") { - for line in content.lines() { - let parts: Vec<&str> = line.split_whitespace().collect(); - // Format: id parent_id maj:min root mount_point options ... - if parts.len() >= 6 { - let mount_point = parts[4].to_string(); - let opts = parts[5].to_string(); - map.insert(mount_point, opts); - } - } - } - map -} - -#[cfg(not(target_os = "linux"))] -fn read_mount_options() -> HashMap { - HashMap::new() -} - -#[cfg(target_os = "linux")] -fn read_interface_ips() -> HashMap> { - let mut map: HashMap> = HashMap::new(); - // Parse /proc/net/if_inet6 for IPv6 addresses - if let Ok(content) = std::fs::read_to_string("/proc/net/if_inet6") { - for line in content.lines() { - let parts: Vec<&str> = line.split_whitespace().collect(); - if parts.len() >= 5 { - let addr_hex = parts[0]; - let iface = parts[4].to_string(); - if addr_hex.len() == 32 { - let ip: String = (0..8) - .map(|i| { - let start = i * 4; - let group = &addr_hex[start..start + 4]; - let trimmed = group.trim_start_matches('0'); - let val = u16::from_str_radix( - if trimmed.is_empty() { "0" } else { trimmed }, - 16, - ) - .unwrap_or(0); - format!("{:x}", val) - }) - .collect::>() - .join(":"); - map.entry(iface).or_default().push(ip); - } - } - } - } - // Parse /proc/net/fib_trie for IPv4 (fallback) - map -} - -#[cfg(not(target_os = "linux"))] -fn read_interface_ips() -> HashMap> { - HashMap::new() -} - -#[cfg(target_os = "linux")] -fn read_batteries() -> Vec { - let mut batteries = Vec::new(); - let power_supply = std::path::Path::new("/sys/class/power_supply"); - if !power_supply.exists() { - return batteries; - } - if let Ok(entries) = std::fs::read_dir(power_supply) { - for entry in entries.flatten() { - let name = match entry.file_name().to_str() { - Some(n) if n.starts_with("BAT") => n.to_string(), - _ => continue, - }; - let base = entry.path(); - let capacity = std::fs::read_to_string(base.join("capacity")) - .ok() - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(0.0); - let state = std::fs::read_to_string(base.join("status")) - .ok() - .map(|s| s.trim().to_string()) - .unwrap_or_default(); - let charge_full = std::fs::read_to_string(base.join("charge_full")) - .ok() - .and_then(|s| s.trim().parse::().ok()); - let charge_now = std::fs::read_to_string(base.join("charge_now")) - .ok() - .and_then(|s| s.trim().parse::().ok()); - let cycles = std::fs::read_to_string(base.join("cycle_count")) - .ok() - .and_then(|s| s.trim().parse::().ok()); - - // time至full/empty estimation from power - let power_now = std::fs::read_to_string(base.join("power_now")) - .ok() - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(0); - let charge_full_design = std::fs::read_to_string(base.join("charge_full_design")) - .ok() - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(1); - - let health = if charge_full_design > 0 { - (charge_full.unwrap_or(1) as f32 / charge_full_design as f32) * 100.0 - } else { - 100.0 - }; - - let (time_to_full, time_to_empty) = if power_now != 0 && power_now.abs() > 0 { - if state == "Charging" { - let remaining = charge_full - .unwrap_or(0) - .saturating_sub(charge_now.unwrap_or(0)); - let secs = (remaining as f64 / power_now.abs() as f64 * 3600.0) as u64; - (Some(secs), None) - } else if state == "Discharging" { - let secs = - (charge_now.unwrap_or(0) as f64 / power_now.abs() as f64 * 3600.0) as u64; - (None, Some(secs)) - } else { - (None, None) - } - } else { - (None, None) - }; - - batteries.push(BatteryInfo { - name, - percentage: capacity, - state, - time_to_full, - time_to_empty, - health, - cycle_count: cycles, - }); - } - } - batteries -} - -#[cfg(not(target_os = "linux"))] -fn read_batteries() -> Vec { - // sysinfo's battery info is limited. On macOS we'd need IOKit. - // On Windows we'd need WMI. For now, return empty. - Vec::new() -} - fn read_gpu_info() -> Vec { - let mut gpus = Vec::new(); - // Try nvidia-smi first (cross-platform, works on Linux and Windows with NVIDIA drivers) - if let Ok(output) = std::process::Command::new("nvidia-smi") - .args([ - "--query-gpu=name,utilization.gpu,memory.total,memory.used,temperature.gpu", - "--format=csv,noheader,nounits", - ]) - .output() - { - if output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - for line in stdout.lines() { - let parts: Vec<&str> = line.split(',').map(|s| s.trim()).collect(); - if parts.len() >= 5 { - let name = parts[0].to_string(); - let usage = parts[1].parse::().unwrap_or(0.0); - let mem_total = parts[2].parse::().unwrap_or(0) * 1024 * 1024; - let mem_used = parts[3].parse::().unwrap_or(0) * 1024 * 1024; - let temp = parts[4].parse::().unwrap_or(0.0); - gpus.push(GpuInfo { - name, - usage, - temperature: temp, - memory_total: mem_total, - memory_used: mem_used, - }); - } - } - } - } + // nvidia-smi is shared by Linux and Windows; the remaining platforms get + // their own fallback probe under platform/. + let mut gpus = read_gpu_info_nvidia_smi(); - // Fallback: try reading from /sys/class/drm/ on Linux - #[cfg(target_os = "linux")] + // Fallback: platform-specific detection (e.g. /sys/class/drm on Linux). if gpus.is_empty() { - if let Ok(entries) = std::fs::read_dir("/sys/class/drm/") { - for entry in entries.flatten() { - let fname = entry.file_name().to_string_lossy().to_string(); - if fname.starts_with("card") && !fname.contains('-') { - let base = entry.path(); - let dev = base.join("device"); - let gpu_name = std::fs::read_to_string(dev.join("product_name")) - .ok() - .map(|s| s.trim().to_string()) - .unwrap_or_else(|| fname.clone()); - let mem_total = std::fs::read_to_string(dev.join("mem_info_vram_total")) - .ok() - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(0); - let mem_used = std::fs::read_to_string(dev.join("mem_info_vram_used")) - .ok() - .and_then(|s| s.trim().parse::().ok()) - .unwrap_or(0); - let temp = find_hwmon_temp(&base.join("device"), "gpu").unwrap_or(0.0); - gpus.push(GpuInfo { - name: gpu_name, - usage: 0.0, - temperature: temp, - memory_total: mem_total, - memory_used: mem_used, - }); - } - } - } + gpus.extend(read_gpu_info_from_sysfs()); } gpus } - -#[cfg(target_os = "linux")] -fn find_hwmon_temp(device_path: &std::path::Path, label_filter: &str) -> Option { - let hwmon = device_path.join("hwmon"); - if hwmon.exists() { - if let Ok(entries) = std::fs::read_dir(&hwmon) { - for entry in entries.flatten() { - let hwmon_dir = entry.path(); - if let Ok(labels) = std::fs::read_to_string(hwmon_dir.join("temp1_label")) { - if labels.trim().to_lowercase().contains(label_filter) { - if let Ok(input) = std::fs::read_to_string(hwmon_dir.join("temp1_input")) { - if let Ok(millideg) = input.trim().parse::() { - return Some(millideg / 1000.0); - } - } - } - } - } - } - } - None -} - -#[cfg(not(target_os = "linux"))] -#[allow(dead_code)] -fn find_hwmon_temp(_device_path: &std::path::Path, _label_filter: &str) -> Option { - None -} - -// --------------------------------------------------------------------------- -// Thread count helper -// --------------------------------------------------------------------------- - -#[cfg(target_os = "linux")] -fn read_thread_count(pid: sysinfo::Pid) -> u64 { - use std::fs; - let path = format!("/proc/{}/status", pid); - if let Ok(content) = fs::read_to_string(&path) { - for line in content.lines() { - if let Some(rest) = line.strip_prefix("Threads:\t") { - return rest.trim().parse::().unwrap_or(0); - } - } - } - 0 -} - -#[cfg(not(target_os = "linux"))] -fn read_thread_count(_pid: sysinfo::Pid) -> u64 { - // Fallback: use tasks count from sysinfo (available on some platforms) - 0 -} diff --git a/src/state/app.rs b/src/state/app.rs new file mode 100644 index 0000000..dfb9f15 --- /dev/null +++ b/src/state/app.rs @@ -0,0 +1,506 @@ +//! Live application state: system sampling per tick, layout/theme/process +//! control, plugins. + +use crate::config::keybinding::{Action, Keybindings}; +use crate::config::{Config, UiStyle}; +use crate::plugins::PluginManager; +use crate::state::history::MetricsHistory; +use crate::state::view::{FullScreenWidget, InputMode, PalettePage, PaletteState, ProcessSortBy}; +use crate::theme::Theme; +use xtop_layout::{layout_index_from_mode, layout_mode_for_name, LayoutDef, LayoutMode}; +use xtop_plugin_api::model::{ProcessInfo, SystemInfo, SystemSnapshot}; +use xtop_plugin_api::{AlertThresholds, PluginWidget, SystemDataProvider}; + +pub struct AppState { + provider: Box, + pub history: MetricsHistory, + pub should_quit: bool, + pub layout_mode: LayoutMode, + pub layout_index: usize, + pub layout_defs: Vec, + pub current_theme: Theme, + pub themes: Vec, + pub selected_theme_index: usize, + pub tick_count: f64, + pub show_help: bool, + pub input_mode: InputMode, + pub search_query: String, + pub full_screen_widget: FullScreenWidget, + pub alerts: AlertThresholds, + pub update_interval_ms: u64, + /// Widget glyph style (chart charset, borders); from the user config. + pub style: UiStyle, + pub palette: PaletteState, + pub keybindings: Keybindings, + pub process_sort: ProcessSortBy, + /// Selected process anchored by PID (not row index), so sorting/filtering + /// or a fresh sample never makes a kill target the wrong process. + pub process_selected_pid: Option, + pub sys_info: SystemInfo, + /// Latest full system sample, computed once per tick and shared by every + /// widget/action in that frame (avoids N samples per frame). + last_snapshot: Option, + pub plugin_manager: Option, + pub plugin_widgets: Vec, +} + +impl AppState { + pub fn new( + provider: Box, + themes: Vec, + config: Config, + layout_defs: Vec, + ) -> Self { + let selected_theme_index = themes + .iter() + .position(|t| t.name == config.theme) + .unwrap_or(0); + let current_theme = themes[selected_theme_index].clone(); + let layout_index = if !config.layout_name.is_empty() { + layout_defs + .iter() + .position(|l| l.name == config.layout_name) + .unwrap_or_else(|| layout_index_from_mode(config.layout_mode, &layout_defs)) + } else { + layout_index_from_mode(config.layout_mode, &layout_defs) + }; + Self { + provider, + history: MetricsHistory::new(config.history_points), + should_quit: false, + layout_mode: config.layout_mode, + layout_index, + layout_defs, + current_theme, + themes, + selected_theme_index, + tick_count: 0.0, + show_help: false, + input_mode: InputMode::Normal, + search_query: String::new(), + full_screen_widget: FullScreenWidget::None, + alerts: config.alerts, + update_interval_ms: config.update_interval_ms, + style: config.style, + palette: PaletteState { + open: false, + query: String::new(), + selected: 0, + entries: Vec::new(), + filtered: Vec::new(), + page: PalettePage::Main, + }, + keybindings: config.keybindings, + process_sort: ProcessSortBy::Cpu, + process_selected_pid: None, + sys_info: SystemInfo::default(), + last_snapshot: None, + plugin_manager: None, + plugin_widgets: Vec::new(), + } + } + + /// Set the plugin manager and inject extra data providers into the composite provider. + /// Called once during initialization after all plugins are registered. + pub fn init_plugins( + &mut self, + mgr: PluginManager, + extra_providers: Vec>, + ) { + if !extra_providers.is_empty() { + self.provider.add_extras(extra_providers); + } + self.plugin_manager = Some(mgr); + self.refresh_plugin_widgets(); + } + + /// Collect plugin widgets into the state for the TUI renderer. + pub fn refresh_plugin_widgets(&mut self) { + if let Some(ref mgr) = self.plugin_manager { + let registrations = mgr.collect_widgets(); + // Only accept if the plugin has RenderWidgets capability + self.plugin_widgets = registrations; + } + } + + /// Kill a process by PID. Returns true if the signal was sent. + pub fn kill_process_by_pid(&mut self, pid: u32) -> bool { + self.provider.kill_process(pid) + } + + /// Set alert thresholds. + pub fn set_alert_thresholds(&mut self, cpu: f64, mem: f64, disk: f64) { + self.alerts = AlertThresholds { + cpu_high: cpu, + mem_high: mem, + disk_high: disk, + }; + } + /// Switch to a theme by name. Returns true if found. + pub fn set_theme_by_name(&mut self, name: &str) -> bool { + if let Some(idx) = self.themes.iter().position(|t| t.name == name) { + self.selected_theme_index = idx; + self.apply_theme(); + true + } else { + false + } + } + + /// Switch to a layout by name. Returns true if found. + pub fn set_layout_by_name(&mut self, name: &str) -> bool { + if let Some(idx) = self.layout_defs.iter().position(|l| l.name == name) { + self.layout_index = idx; + self.sync_layout_mode(); + self.full_screen_widget = FullScreenWidget::None; + true + } else { + false + } + } + + pub fn current_layout(&self) -> &LayoutDef { + &self.layout_defs[self.layout_index] + } + + /// The layout mode matching the current definition. Custom layouts fall + /// back to the previously active mode (they are addressed by name). + pub fn save_layout_mode(&self) -> LayoutMode { + let fallback = if self.layout_index < 7 { + xtop_layout::mode_from_layout_index(self.layout_index) + } else { + self.layout_mode + }; + match self.layout_defs.get(self.layout_index) { + Some(def) => layout_mode_for_name(&def.name, fallback), + None => LayoutMode::Dashboard, + } + } + + fn sync_layout_mode(&mut self) { + self.layout_mode = self.save_layout_mode(); + } + + /// Safely access the plugin manager with a closure. + /// Ensures the plugin manager is always restored after the operation. + /// Returns `None` when no manager is initialized (pre-bootstrap or tests) + /// instead of panicking; callers decide how to degrade. + /// NOTE: does NOT call refresh_plugin_widgets — the caller must do it if needed. + pub fn with_plugin_manager_mut( + &mut self, + f: impl FnOnce(&mut PluginManager, &mut Self) -> R, + ) -> Option { + let mut mgr = self.plugin_manager.take()?; + let result = f(&mut mgr, self); + self.plugin_manager = Some(mgr); + Some(result) + } + + pub fn on_tick(&mut self) { + self.provider.refresh_all(); + self.tick_count += 1.0; + let info = self.provider.system_info(); + if !info.hostname.is_empty() { + self.sys_info = info; + } + let x = self.tick_count; + let snap = self.provider.snapshot(); + + if snap.cpus.len() != self.history.cpu.len() { + self.history.reset_cpu(snap.cpus.len()); + } + for (i, cpu) in snap.cpus.iter().enumerate() { + self.history.push_cpu(i, x, cpu.usage); + } + + self.history.push_mem(x, snap.memory.percent); + + // Network history tracks *rates* (bytes/s), not cumulative counters, + // so the chart shows throughput over time. + let total_rx_speed: f64 = snap.networks.iter().map(|n| n.rx_speed).sum(); + let total_tx_speed: f64 = snap.networks.iter().map(|n| n.tx_speed).sum(); + self.history.push_net(x, total_rx_speed, total_tx_speed); + + // Cache the sample for every widget and action in this frame. + self.last_snapshot = Some(snap); + + // Let plugins tick + let _ = self.with_plugin_manager_mut(|mgr, this| { + mgr.tick_all(this); + }); + self.refresh_plugin_widgets(); + } + + /// The current sample (one per tick). Widgets and process actions read + /// this instead of resampling the system every frame. + pub fn snapshot_cache(&self) -> Option<&SystemSnapshot> { + self.last_snapshot.as_ref() + } + + /// Full current snapshot. Prefer [`AppState::snapshot_cache`] in render + /// paths; this forces a fresh system sample (used by plugin hosts). + pub fn snapshot(&self) -> SystemSnapshot { + self.last_snapshot + .clone() + .unwrap_or_else(|| self.provider.snapshot()) + } + + /// The process rows the UI shows: search filter + user sort, applied to + /// one shared sample. Single source of truth for the processes widget and + /// the Up/Down/Kill actions (selection is anchored by PID). + pub fn sorted_processes<'a>(&'a self, snap: &'a SystemSnapshot) -> Vec<&'a ProcessInfo> { + let mut items: Vec<&ProcessInfo> = if self.search_query.is_empty() { + snap.processes.iter().collect() + } else { + let q = self.search_query.to_lowercase(); + snap.processes + .iter() + .filter(|p| p.name.to_lowercase().contains(&q)) + .collect() + }; + match self.process_sort { + ProcessSortBy::Cpu => items.sort_by(|a, b| { + b.cpu_usage + .partial_cmp(&a.cpu_usage) + .unwrap_or(std::cmp::Ordering::Equal) + }), + ProcessSortBy::Memory => items.sort_by_key(|b| std::cmp::Reverse(b.memory)), + ProcessSortBy::Pid => items.sort_by_key(|a| a.pid), + ProcessSortBy::Name => items.sort_by_key(|a| a.name.to_lowercase()), + } + items + } + + pub fn next_theme(&mut self) { + self.selected_theme_index = (self.selected_theme_index + 1) % self.themes.len(); + self.apply_theme(); + } + + pub fn previous_theme(&mut self) { + if self.selected_theme_index == 0 { + self.selected_theme_index = self.themes.len() - 1; + } else { + self.selected_theme_index -= 1; + } + self.apply_theme(); + } + + fn apply_theme(&mut self) { + self.current_theme = self.themes[self.selected_theme_index].clone(); + } + + pub fn next_layout(&mut self) { + if self.layout_defs.is_empty() { + return; + } + self.layout_index = (self.layout_index + 1) % self.layout_defs.len(); + self.sync_layout_mode(); + self.full_screen_widget = FullScreenWidget::None; + } + + pub fn current_layout_name(&self) -> &str { + &self.layout_defs[self.layout_index].name + } + + pub fn toggle_fullscreen(&mut self) { + self.full_screen_widget = match self.full_screen_widget { + FullScreenWidget::None => FullScreenWidget::Cpu, + _ => FullScreenWidget::None, + }; + } + + pub fn cycle_fullscreen_widget(&mut self) { + self.full_screen_widget = self.full_screen_widget.next(); + } + + pub fn start_search(&mut self) { + self.input_mode = InputMode::Searching; + self.search_query.clear(); + } + + pub fn search_push_char(&mut self, c: char) { + self.search_query.push(c); + } + + pub fn search_pop_char(&mut self) { + self.search_query.pop(); + } + + pub fn end_search(&mut self) { + self.input_mode = InputMode::Normal; + } + + pub fn toggle_help(&mut self) { + self.show_help = !self.show_help; + } + + pub fn quit(&mut self) { + self.should_quit = true; + } + + pub fn process_select_next(&mut self) { + self.move_process_selection(1); + } + + pub fn process_select_prev(&mut self) { + self.move_process_selection(-1); + } + + fn move_process_selection(&mut self, dir: i32) { + let Some(snap) = self.snapshot_cache() else { + return; + }; + let view = self.sorted_processes(snap); + if view.is_empty() { + return; + } + let n = view.len() as i32; + let pos = self + .process_selected_pid + .and_then(|pid| view.iter().position(|p| p.pid == pid)) + .unwrap_or(0) as i32; + let next = (pos + dir).rem_euclid(n); + self.process_selected_pid = Some(view[next as usize].pid); + } + + pub fn cycle_sort(&mut self) { + self.process_sort = self.process_sort.next(); + self.process_selected_pid = None; + } + + pub fn execute_action(&mut self, action: &Action) { + match action { + Action::Quit => self.quit(), + Action::ToggleHelp => self.toggle_help(), + Action::NextTheme => self.next_theme(), + Action::PreviousTheme => self.previous_theme(), + Action::NextLayout => self.next_layout(), + Action::ToggleFullscreen => self.toggle_fullscreen(), + Action::CycleFullscreen => self.cycle_fullscreen_widget(), + Action::Search => self.start_search(), + Action::OpenCommandPalette => {} + Action::Cancel => { + if self.show_help { + self.toggle_help(); + } + } + Action::SelectTheme(i) => { + self.selected_theme_index = *i; + self.apply_theme(); + } + Action::SelectLayout(i) => { + if *i < self.layout_defs.len() { + self.layout_index = *i; + self.sync_layout_mode(); + self.full_screen_widget = FullScreenWidget::None; + } + } + Action::NavigateThemes => { + self.palette_navigate_to(PalettePage::Themes); + return; + } + Action::NavigateLayouts => { + self.palette_navigate_to(PalettePage::Layouts); + return; + } + Action::KillProcess => { + if let Some(pid) = self.process_selected_pid { + self.provider.kill_process(pid); + self.process_selected_pid = None; + } + } + Action::ProcessUp => self.process_select_prev(), + Action::ProcessDown => self.process_select_next(), + Action::SortByCpu => { + self.cycle_sort(); + } + Action::RandomTheme => { + let n = self.themes.len(); + if n > 1 { + let next = (self.selected_theme_index + 7) % n; + self.selected_theme_index = next; + self.apply_theme(); + } + } + } + // Close palette after executing any action (except navigation which returns above) + if self.input_mode == InputMode::CommandPalette { + self.close_palette(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::default_alerts; + use xtop_layout::{default_layouts, LayoutMode}; + + fn test_state(defs: Vec) -> AppState { + let theme = Theme { + name: "test".into(), + palette: [[0, 0, 0]; 16], + }; + AppState::new( + Box::new(crate::providers::sysinfo::SysinfoProvider::new()), + vec![theme], + Config::default(), + defs, + ) + } + + #[test] + fn test_fullscreen_widget_cycle() { + assert_eq!(FullScreenWidget::None.next(), FullScreenWidget::Cpu); + assert_eq!(FullScreenWidget::Battery.next(), FullScreenWidget::None); + } + + #[test] + fn test_alert_thresholds_default() { + let a = default_alerts(); + assert_eq!(a.cpu_high, 90.0); + assert_eq!(a.mem_high, 90.0); + assert_eq!(a.disk_high, 90.0); + } + + #[test] + fn test_config_default() { + let c = Config::default(); + assert_eq!(c.theme, "x"); + assert_eq!(c.layout_mode, LayoutMode::Dashboard); + assert_eq!(c.update_interval_ms, 1000); + } + + #[test] + fn test_snapshot_cache_empty_before_first_tick() { + let state = test_state(vec![]); + assert!(state.snapshot_cache().is_none()); + } + + #[test] + fn test_custom_layout_keeps_previous_mode() { + let mut defs = default_layouts(); + defs.push(LayoutDef { + name: "My Custom".into(), + root: xtop_layout::LayoutNode::Split { + direction: xtop_layout::Direction::Vertical, + areas: vec![], + }, + }); + let mut state = test_state(defs.clone()); + + // Default boot: Dashboard at index 0. + assert_eq!(state.layout_index, 0); + assert_eq!(state.layout_mode, LayoutMode::Dashboard); + + // Custom layout (index 7) must not reset the mode to Dashboard-as-mode + // loss; it falls back to the previously active mode (Dashboard here). + assert!(state.set_layout_by_name("My Custom")); + assert_eq!(state.layout_mode, LayoutMode::Dashboard); + assert_eq!(state.current_layout_name(), "My Custom"); + + // Built-ins still resolve to their real mode by name. + assert!(state.set_layout_by_name("CPU Focus")); + assert_eq!(state.layout_mode, LayoutMode::CpuFocus); + } +} diff --git a/crates/xtop-core/src/application/history.rs b/src/state/history.rs similarity index 99% rename from crates/xtop-core/src/application/history.rs rename to src/state/history.rs index dafd59c..3cf0c51 100644 --- a/crates/xtop-core/src/application/history.rs +++ b/src/state/history.rs @@ -19,6 +19,7 @@ impl MetricsHistory { } } + #[cfg(test)] pub fn set_max_points(&mut self, max: usize) { self.max_points = max; self.mem.truncate(max); diff --git a/src/state/mod.rs b/src/state/mod.rs new file mode 100644 index 0000000..56f7dc9 --- /dev/null +++ b/src/state/mod.rs @@ -0,0 +1,14 @@ +//! State area: live application state and metrics history. +//! +//! `app` holds the full `AppState`; `history` keeps historical snapshots for +//! charts; `view` groups the view-control types (fullscreen, input mode, +//! palette). The persisted config schema lives under `config`. + +pub mod app; +pub mod history; +mod palette; +pub mod view; +mod widget_state; + +pub use app::*; +pub use view::*; diff --git a/src/state/palette.rs b/src/state/palette.rs new file mode 100644 index 0000000..0be970e --- /dev/null +++ b/src/state/palette.rs @@ -0,0 +1,134 @@ +//! Command palette state logic (open/navigate/filter/execute pages). +//! +//! Lives in its own module to keep `AppState` focused; these functions only +//! touch public state fields, so they are plain `impl` additions. + +use crate::config::keybinding::Action; +use crate::state::view::{InputMode, PaletteEntry, PalettePage}; + +use super::AppState; + +impl AppState { + pub fn rebuild_palette(&mut self) { + self.palette.entries.clear(); + match self.palette.page { + PalettePage::Main => { + self.palette.entries.push(PaletteEntry { + label: "Themes →".into(), + action: Action::NavigateThemes, + }); + self.palette.entries.push(PaletteEntry { + label: "Layouts →".into(), + action: Action::NavigateLayouts, + }); + self.palette.entries.push(PaletteEntry { + label: "Toggle Fullscreen".into(), + action: Action::ToggleFullscreen, + }); + self.palette.entries.push(PaletteEntry { + label: "Cycle Fullscreen Widget".into(), + action: Action::CycleFullscreen, + }); + self.palette.entries.push(PaletteEntry { + label: "Search Processes".into(), + action: Action::Search, + }); + self.palette.entries.push(PaletteEntry { + label: "Toggle Help".into(), + action: Action::ToggleHelp, + }); + self.palette.entries.push(PaletteEntry { + label: format!("Sort: {}", self.process_sort.label()), + action: Action::SortByCpu, + }); + self.palette.entries.push(PaletteEntry { + label: "Random Theme".into(), + action: Action::RandomTheme, + }); + self.palette.entries.push(PaletteEntry { + label: "Exit".into(), + action: Action::Quit, + }); + } + PalettePage::Themes => { + for (i, theme) in self.themes.iter().enumerate() { + self.palette.entries.push(PaletteEntry { + label: theme.name.clone(), + action: Action::SelectTheme(i), + }); + } + } + PalettePage::Layouts => { + for (i, layout) in self.layout_defs.iter().enumerate() { + self.palette.entries.push(PaletteEntry { + label: layout.name.clone(), + action: Action::SelectLayout(i), + }); + } + } + } + self.palette_filter(); + } + + pub fn open_palette(&mut self) { + self.palette.open = true; + self.palette.query.clear(); + self.palette.selected = 0; + self.palette.page = PalettePage::Main; + self.rebuild_palette(); + } + + pub fn palette_navigate_to(&mut self, page: PalettePage) { + self.palette.page = page; + self.palette.query.clear(); + self.palette.selected = 0; + self.rebuild_palette(); + } + + pub fn palette_filter(&mut self) { + let q = self.palette.query.to_lowercase(); + self.palette.filtered = self + .palette + .entries + .iter() + .enumerate() + .filter(|(_, e)| q.is_empty() || e.label.to_lowercase().contains(&q)) + .map(|(i, _)| i) + .collect(); + if !self.palette.filtered.is_empty() { + self.palette.selected = self.palette.selected.min(self.palette.filtered.len() - 1); + } else { + self.palette.selected = 0; + } + } + + pub fn palette_select_next(&mut self) { + if !self.palette.filtered.is_empty() { + self.palette.selected = (self.palette.selected + 1) % self.palette.filtered.len(); + } + } + + pub fn palette_select_prev(&mut self) { + if !self.palette.filtered.is_empty() { + self.palette.selected = if self.palette.selected == 0 { + self.palette.filtered.len() - 1 + } else { + self.palette.selected - 1 + }; + } + } + + pub fn palette_selected_action(&self) -> Option { + self.palette + .filtered + .get(self.palette.selected) + .and_then(|&i| self.palette.entries.get(i)) + .map(|e| e.action.clone()) + } + + pub fn close_palette(&mut self) { + self.palette.open = false; + self.palette.page = PalettePage::Main; + self.input_mode = InputMode::Normal; + } +} diff --git a/src/state/view.rs b/src/state/view.rs new file mode 100644 index 0000000..b252450 --- /dev/null +++ b/src/state/view.rs @@ -0,0 +1,118 @@ +//! View-control state shared between the kernel state and the UI. +//! +//! Fullscreen targets, input modes and the command palette state. + +use crate::config::keybinding::Action; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum FullScreenWidget { + #[default] + None, + Cpu, + Memory, + Storage, + Network, + Processes, + DiskIO, + Gpu, + Battery, +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub enum ProcessSortBy { + Cpu, + Memory, + Pid, + Name, +} + +impl ProcessSortBy { + pub fn next(self) -> Self { + match self { + Self::Cpu => Self::Memory, + Self::Memory => Self::Pid, + Self::Pid => Self::Name, + Self::Name => Self::Cpu, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::Cpu => "CPU%", + Self::Memory => "Mem", + Self::Pid => "PID", + Self::Name => "Name", + } + } +} + +impl FullScreenWidget { + pub fn next(self) -> Self { + match self { + Self::None => Self::Cpu, + Self::Cpu => Self::Memory, + Self::Memory => Self::Storage, + Self::Storage => Self::Network, + Self::Network => Self::Processes, + Self::Processes => Self::DiskIO, + Self::DiskIO => Self::Gpu, + Self::Gpu => Self::Battery, + Self::Battery => Self::None, + } + } + + pub fn label(self) -> &'static str { + match self { + Self::None => "", + Self::Cpu => "CPU", + Self::Memory => "Memory", + Self::Storage => "Storage", + Self::Network => "Network", + Self::Processes => "Processes", + Self::DiskIO => "Disk I/O", + Self::Gpu => "GPU", + Self::Battery => "Battery", + } + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PaletteEntry { + pub label: String, + pub action: Action, +} + +#[derive(Clone, Debug, PartialEq)] +pub enum PalettePage { + Main, + Themes, + Layouts, +} + +#[derive(Clone, Debug, PartialEq)] +pub struct PaletteState { + pub open: bool, + pub query: String, + pub selected: usize, + pub entries: Vec, + pub filtered: Vec, + pub page: PalettePage, +} + +impl PaletteState { + pub fn title(&self) -> &str { + match self.page { + PalettePage::Main => "Command Palette", + PalettePage::Themes => "Select Theme", + PalettePage::Layouts => "Select Layout", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum InputMode { + Normal, + Searching, + CommandPalette, +} diff --git a/src/state/widget_state.rs b/src/state/widget_state.rs new file mode 100644 index 0000000..3ae2500 --- /dev/null +++ b/src/state/widget_state.rs @@ -0,0 +1,98 @@ +//! Kernel implementation of the widget renderer contract +//! ([`xtop_widget_api::WidgetState`]). +//! +//! This is the single door widget packs cross: they render against this view +//! and never touch kernel types. + +use crate::state::app::AppState; +use crate::state::view::{FullScreenWidget, InputMode}; +use xtop_plugin_api::model::{ProcessInfo, SystemSnapshot}; + +impl xtop_widget_api::WidgetState for AppState { + fn snapshot(&self) -> Option<&SystemSnapshot> { + self.snapshot_cache() + } + + fn theme_name(&self) -> &str { + &self.current_theme.name + } + + fn theme_fg(&self) -> &[u8; 3] { + self.current_theme.fg() + } + + fn theme_bg(&self) -> &[u8; 3] { + self.current_theme.bg() + } + + fn theme_palette(&self) -> &[[u8; 3]; 16] { + &self.current_theme.palette + } + + fn alerts(&self) -> xtop_plugin_api::AlertThresholds { + self.alerts.clone() + } + + fn charset(&self, widget: &str) -> xtop_widget_api::ChartCharset { + self.style.charset_for(widget) + } + + fn borders(&self, widget: &str) -> xtop_widget_api::WidgetBorders { + self.style.borders_for(widget) + } + + fn cpu_history(&self) -> &[std::collections::VecDeque<(f64, f64)>] { + &self.history.cpu + } + + fn mem_history(&self) -> &std::collections::VecDeque<(f64, f64)> { + &self.history.mem + } + + fn net_rx_history(&self) -> &std::collections::VecDeque<(f64, f64)> { + &self.history.net_rx + } + + fn net_tx_history(&self) -> &std::collections::VecDeque<(f64, f64)> { + &self.history.net_tx + } + + fn search_query(&self) -> &str { + &self.search_query + } + + fn process_selected_pid(&self) -> Option { + self.process_selected_pid + } + + fn process_sort_label(&self) -> &str { + self.process_sort.label() + } + + fn layout_name(&self) -> &str { + self.current_layout_name() + } + + fn is_searching(&self) -> bool { + self.input_mode == InputMode::Searching + } + + fn fullscreen_label(&self) -> Option<&str> { + if self.full_screen_widget == FullScreenWidget::None { + None + } else { + Some(self.full_screen_widget.label()) + } + } + + fn sys_info(&self) -> xtop_plugin_api::SystemInfo { + self.sys_info.clone() + } + + fn process_view(&self) -> Vec<&ProcessInfo> { + let Some(snap) = self.snapshot_cache() else { + return Vec::new(); + }; + self.sorted_processes(snap) + } +} diff --git a/crates/xtop-core/src/infrastructure/theme_loader.rs b/src/theme/loader.rs similarity index 76% rename from crates/xtop-core/src/infrastructure/theme_loader.rs rename to src/theme/loader.rs index 653aeeb..149c9fd 100644 --- a/crates/xtop-core/src/infrastructure/theme_loader.rs +++ b/src/theme/loader.rs @@ -1,11 +1,12 @@ -use crate::domain::theme::Theme; +//! Theme loading: embedded default theme and user themes. +use crate::theme::Theme; use std::fs; use std::path::Path; fn make_theme(name: &str, colors: [&str; 16]) -> Theme { let mut palette = [[0u8; 3]; 16]; for (i, h) in colors.iter().enumerate() { - palette[i] = crate::domain::theme::hex_to_rgb_pub(h); + palette[i] = crate::theme::hex_to_rgb_pub(h); } Theme { name: name.to_string(), @@ -23,7 +24,7 @@ fn default_theme() -> Theme { ) } -fn strip_jsonc_comments(input: &str) -> String { +pub(crate) fn strip_jsonc_comments(input: &str) -> String { let mut out = String::with_capacity(input.len()); let chars: Vec = input.chars().collect(); let mut i = 0; @@ -76,36 +77,31 @@ fn load_themes_from_dir(dir: &Path) -> Vec { themes } +/// Where user themes live: the platform-aware config dir (same tree as +/// `config.json` and the layouts), joined with `themes`. This keeps macOS +/// (`~/Library/Application Support/xtop/themes`) and Windows +/// (`%APPDATA%\xtop\themes`) consistent with the rest of the app instead of +/// hard-coding the XDG `~/.config` layout. pub fn themes_dir() -> std::path::PathBuf { - if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") { - std::path::PathBuf::from(xdg).join("xtop").join("themes") - } else if let Ok(home) = std::env::var("HOME") { - std::path::PathBuf::from(home) - .join(".config") - .join("xtop") - .join("themes") - } else { - std::path::PathBuf::from(".") - .join(".config") - .join("xtop") - .join("themes") - } + crate::config::config_dir().join("themes") } pub fn load_all_themes() -> Vec { + // Defaults first (index 0 = "x", stable palette position), then user + // files: a user theme reusing a default name overrides it in place + // (parity with layouts); new names are appended. let mut themes = vec![default_theme()]; - - let user_dir = themes_dir(); - let custom = load_themes_from_dir(&user_dir); - for t in custom { - if !themes.iter().any(|existing| existing.name == t.name) { + for t in load_themes_from_dir(&themes_dir()) { + if let Some(slot) = themes.iter_mut().find(|existing| existing.name == t.name) { + *slot = t; + } else { themes.push(t); } } - themes } +#[cfg(test)] pub fn builtin_themes() -> Vec { vec![default_theme()] } @@ -150,7 +146,7 @@ mod tests { #[test] fn test_hex_to_rgb() { - let result = crate::domain::theme::hex_to_rgb_pub("#ff0000"); + let result = crate::theme::hex_to_rgb_pub("#ff0000"); assert_eq!(result, [255, 0, 0]); } } diff --git a/src/theme/mod.rs b/src/theme/mod.rs new file mode 100644 index 0000000..082e545 --- /dev/null +++ b/src/theme/mod.rs @@ -0,0 +1,7 @@ +//! Theme area: color theme model, palettes and theme loading. + +mod loader; +mod model; + +pub use loader::*; +pub use model::*; diff --git a/crates/xtop-core/src/domain/theme.rs b/src/theme/model.rs similarity index 84% rename from crates/xtop-core/src/domain/theme.rs rename to src/theme/model.rs index 3fa6c8a..4dd5a57 100644 --- a/crates/xtop-core/src/domain/theme.rs +++ b/src/theme/model.rs @@ -1,15 +1,8 @@ +//! Theme data model and its custom (hex-string) deserializer. use serde::de::{self, Deserializer, MapAccess, Visitor}; use serde::{Deserialize, Serialize}; use std::fmt; -fn hex_to_rgb(hex: &str) -> [u8; 3] { - let hex = hex.trim_start_matches('#'); - let r = u8::from_str_radix(hex.get(0..2).unwrap_or("00"), 16).unwrap_or(0); - let g = u8::from_str_radix(hex.get(2..4).unwrap_or("00"), 16).unwrap_or(0); - let b = u8::from_str_radix(hex.get(4..6).unwrap_or("00"), 16).unwrap_or(0); - [r, g, b] -} - #[derive(Clone, Debug, Serialize)] pub struct Theme { pub name: String, @@ -76,7 +69,7 @@ impl<'de> Deserialize<'de> for Theme { let mut palette = [[0u8; 3]; 16]; for (i, hex) in palette_str.iter().enumerate() { - palette[i] = hex_to_rgb(hex); + palette[i] = xtop_plugin_api::hex_to_rgb(hex); } Ok(Theme { name, palette }) @@ -87,6 +80,6 @@ impl<'de> Deserialize<'de> for Theme { } } -pub fn hex_to_rgb_pub(hex: &str) -> [u8; 3] { - hex_to_rgb(hex) +pub(crate) fn hex_to_rgb_pub(hex: &str) -> [u8; 3] { + xtop_plugin_api::hex_to_rgb(hex) } diff --git a/src/ui/effects/mod.rs b/src/ui/effects/mod.rs new file mode 100644 index 0000000..e422518 --- /dev/null +++ b/src/ui/effects/mod.rs @@ -0,0 +1,20 @@ +//! Optional frame-effect host (feature `effects`). +//! +//! Effects (see `xtop-effect-api`) transform the fully rendered frame +//! buffer after layout and before the terminal flush. The kernel hosts at +//! most one active effect, named by the optional persisted `effect` config +//! key, and drives it with the time elapsed since the run loop started. +//! +//! When the feature is off this module still compiles (same type, idle +//! no-op), so the run loop stays feature-agnostic: default builds carry +//! zero extra dependencies and zero runtime cost. + +#[cfg(not(feature = "effects"))] +mod off; +#[cfg(feature = "effects")] +mod on; + +#[cfg(not(feature = "effects"))] +pub use off::EffectHost; +#[cfg(feature = "effects")] +pub use on::EffectHost; diff --git a/src/ui/effects/off.rs b/src/ui/effects/off.rs new file mode 100644 index 0000000..50cc740 --- /dev/null +++ b/src/ui/effects/off.rs @@ -0,0 +1,19 @@ +//! Idle effect host for builds without the `effects` feature. +//! +//! Same public surface as the feature-on host (see the parent module doc), +//! but zero-sized: the config key is ignored, `apply` is a no-op and no +//! effect dependency is compiled in. + +/// Idle frame-effect host (feature `effects` not compiled in). +pub struct EffectHost; + +impl EffectHost { + /// Idle host: without the feature there is no effect to activate, so + /// the config value is deliberately ignored. + pub fn from_config(_name: Option<&str>) -> Self { + Self + } + + /// No-op: the feature is off, the frame is passed through untouched. + pub fn apply(&mut self, _buffer: &mut ratatui::buffer::Buffer) {} +} diff --git a/src/ui/effects/on.rs b/src/ui/effects/on.rs new file mode 100644 index 0000000..597a432 --- /dev/null +++ b/src/ui/effects/on.rs @@ -0,0 +1,67 @@ +//! Effect host compiled when the `effects` feature is on. +//! +//! The host owns the optional active effect plus the instant the run loop +//! started. The run loop calls [`EffectHost::apply`] after every render; +//! with no active effect the call is a cheap no-op (no allocation). + +use std::time::Instant; + +use xtop_effect_api::Effect; +use xtop_effect_fade::FadeEffect; + +/// Runtime state of the frame-effect host. +pub struct EffectHost { + /// When the run loop started; the effect contract's clock (`elapsed` + /// grows from here, never resets). + started: Instant, + /// The active effect, when the config named one we know. + effect: Option>, +} + +impl EffectHost { + /// Create the host from the persisted `effect` config value. + /// + /// `"fade"` activates the built-in `FadeEffect`; an absent, empty or + /// unknown name leaves the host idle (no effect, no allocation). + pub fn from_config(name: Option<&str>) -> Self { + Self { + started: Instant::now(), + effect: select_effect(name), + } + } + + /// Apply the active effect to a rendered frame (after layout, before + /// flush). No-op when no effect is active. + pub fn apply(&mut self, buffer: &mut ratatui::buffer::Buffer) { + if let Some(effect) = self.effect.as_deref_mut() { + effect.on_frame(buffer, self.started.elapsed()); + } + } +} + +/// Map a config value to an effect instance. `None`/empty and unknown names +/// select no effect; the only built-in id is `"fade"`. +pub fn select_effect(name: Option<&str>) -> Option> { + match name { + Some("fade") => Some(Box::new(FadeEffect)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::select_effect; + + #[test] + fn fade_config_value_selects_the_fade_effect() { + let effect = select_effect(Some("fade")).expect("fade must resolve"); + assert_eq!(effect.manifest().id, "fade"); + } + + #[test] + fn absent_empty_or_unknown_names_select_no_effect() { + assert!(select_effect(None).is_none()); + assert!(select_effect(Some("")).is_none()); + assert!(select_effect(Some("wipe")).is_none()); + } +} diff --git a/src/ui/layout/engine.rs b/src/ui/layout/engine.rs new file mode 100644 index 0000000..2b30125 --- /dev/null +++ b/src/ui/layout/engine.rs @@ -0,0 +1,213 @@ +//! Layout render engine: splits rects and dispatches widget renderers. +//! +//! Widgets live in packs (see the `widgets` repo); the kernel resolves +//! `(pack, name)` at render time. Plugin widgets ([`PluginWidget`]) keep +//! precedence over packs and can replace any name. Unknown names are +//! reported once per process (see [`warn_unknown_widget`]). + +use crate::state::AppState; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::Frame; +use std::collections::HashMap; +use std::collections::HashSet; +use std::sync::{Mutex, OnceLock}; +use xtop_layout::{Direction, LayoutArea, LayoutConstraint, LayoutDef, LayoutNode}; +use xtop_plugin_api::PluginWidget; +use xtop_widget_api::WidgetRenderer; + +/// One compiled-in widget pack. +struct Pack { + name: &'static str, + renderers: &'static HashMap<&'static str, WidgetRenderer>, +} + +static BASE_PACK: OnceLock> = OnceLock::new(); +#[cfg(feature = "widget-blocks")] +static BLOCKS_PACK: OnceLock> = OnceLock::new(); + +/// The packs compiled into this binary, in precedence order. +fn packs() -> &'static [Pack] { + static PACKS: OnceLock> = OnceLock::new(); + PACKS.get_or_init(|| { + // `mut` is only used when the blocks pack is compiled in. + #[cfg_attr(not(feature = "widget-blocks"), allow(unused_mut))] + let mut v = vec![Pack { + name: "default", + renderers: BASE_PACK.get_or_init(xtop_widgets::registry), + }]; + #[cfg(feature = "widget-blocks")] + v.push(Pack { + name: "blocks", + renderers: BLOCKS_PACK.get_or_init(xtop_widget_blocks::registry), + }); + v + }) +} + +/// Resolve the renderer for a widget name following the user's pack choice +/// (`style.pack` global or per-widget `style.widgets..pack`). Unknown +/// packs and names gracefully fall back to the base pack. +fn resolve(state: &AppState, name: &str) -> Option<&'static WidgetRenderer> { + let packs = packs(); + let chosen = state.style.pack_for(name); + if let Some(pack_name) = chosen { + if let Some(pack) = packs.iter().find(|p| p.name == pack_name) { + if let Some(r) = pack.renderers.get(name) { + return Some(r); + } + } + } + packs + .iter() + .find(|p| p.name == "default") + .and_then(|p| p.renderers.get(name)) +} + +/// Render a layout definition within a given area. +/// +/// `plugin_widgets` is an optional extension from plugins; plugin widgets +/// take precedence over every pack. +pub fn render_layout( + f: &mut Frame, + state: &AppState, + area: Rect, + def: &LayoutDef, + plugin_widgets: &HashMap, +) { + warn_unknown_widgets(state, def, plugin_widgets); + render_node(f, state, area, &def.root, plugin_widgets); +} + +/// Render a single named widget (used by fullscreen and minimal views). +/// Returns false when no renderer is registered for the name. +pub fn render_named( + f: &mut Frame, + state: &AppState, + name: &str, + area: Rect, + plugin_widgets: &HashMap, +) -> bool { + if let Some(widget) = plugin_widgets.get(name) { + (widget.render)(f, state, area); + return true; + } + if let Some(render_fn) = resolve(state, name) { + render_fn(f, state, area); + return true; + } + false +} + +fn render_node( + f: &mut Frame, + state: &AppState, + area: Rect, + node: &LayoutNode, + plugin_widgets: &HashMap, +) { + match node { + LayoutNode::Widget { name } => { + render_named(f, state, name, area, plugin_widgets); + } + LayoutNode::Split { direction, areas } => { + if areas.is_empty() { + return; + } + let constraints: Vec = areas.iter().map(to_ratatui_constraint).collect(); + let chunks = match direction { + Direction::Horizontal => Layout::horizontal(constraints).split(area), + Direction::Vertical => Layout::vertical(constraints).split(area), + }; + for (i, chunk) in chunks.iter().enumerate() { + if i < areas.len() { + render_node(f, state, *chunk, &areas[i].node, plugin_widgets); + } + } + } + } +} + +fn to_ratatui_constraint(area: &LayoutArea) -> Constraint { + match area.constraint { + LayoutConstraint::Length(n) => Constraint::Length(n), + LayoutConstraint::Percentage(p) => Constraint::Percentage(p), + LayoutConstraint::Fill => Constraint::Fill(1), + } +} + +// --------------------------------------------------------------------------- +// Unknown-widget reporting (DR-3): a layout referencing a name no pack or +// plugin provides renders an empty area; that is a user-facing mistake, so +// the kernel warns once per widget name per process. +// --------------------------------------------------------------------------- + +/// Widget names already reported as unknown this process. +static REPORTED_UNKNOWN_WIDGETS: OnceLock>> = OnceLock::new(); + +/// Emit at most one stderr warning per unknown widget name per process. +fn warn_unknown_widget(layout: &str, name: &str) { + if report_unknown_widget(name) { + eprintln!("xtop: layout '{layout}' references unknown widget '{name}'"); + } +} + +/// Mark a widget name as reported. Returns `true` on the first call for a +/// given name, `false` for every later call (the one-warning-per-name +/// guarantee). The set never shrinks, so the render path only pays for a +/// name once per process. +fn report_unknown_widget(name: &str) -> bool { + let reported = REPORTED_UNKNOWN_WIDGETS.get_or_init(|| Mutex::new(HashSet::new())); + let Ok(mut seen) = reported.lock() else { + return true; + }; + seen.insert(name.to_string()) +} + +/// Walk the layout tree once per frame and warn about leaf names no pack or +/// plugin can render. Cheap (pure name collection over a small tree); the +/// per-name dedup keeps repeated frames silent. +fn warn_unknown_widgets( + state: &AppState, + def: &LayoutDef, + plugin_widgets: &HashMap, +) { + fn walk<'a>(node: &'a LayoutNode, names: &mut Vec<&'a str>) { + match node { + LayoutNode::Widget { name } => names.push(name), + LayoutNode::Split { areas, .. } => { + for area in areas { + walk(&area.node, names); + } + } + } + } + + let mut names = Vec::new(); + walk(&def.root, &mut names); + for name in names { + if !plugin_widgets.contains_key(name) && resolve(state, name).is_none() { + warn_unknown_widget(&def.name, name); + } + } +} + +#[cfg(test)] +mod tests { + use super::{report_unknown_widget, REPORTED_UNKNOWN_WIDGETS}; + use std::collections::HashSet; + use std::sync::Mutex; + + #[test] + fn unknown_widget_warns_once_per_name() { + // Reset the process-wide set so this test is order-independent. + REPORTED_UNKNOWN_WIDGETS + .set(Mutex::new(HashSet::new())) + .ok(); + // First report of a name returns true (a warning is emitted)... + assert!(report_unknown_widget("ghost")); + // ...every later report of the same name is silent. + assert!(!report_unknown_widget("ghost")); + // A different name still warns. + assert!(report_unknown_widget("phantom")); + } +} diff --git a/src/ui/layout/mod.rs b/src/ui/layout/mod.rs new file mode 100644 index 0000000..53c2f9a --- /dev/null +++ b/src/ui/layout/mod.rs @@ -0,0 +1,6 @@ +//! Layout area: the engine that maps layout definitions onto widget +//! renderers, plus the built-in widget registry. + +mod engine; + +pub use engine::*; diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..045b1b9 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,18 @@ +//! UI area of xtop: terminal setup, screen composition, overlays and the +//! widget engine. +//! +//! - [`terminal`] terminal backend lifecycle (raw mode, alternate screen) +//! - [`screen`] top-level composition (fullscreen, minimal, layout) +//! - [`layout`] layout engine: rect split + widget pack resolution +//! - [`effects`] optional frame-effect host (feature `effects`) +//! - [`overlay`] kernel-owned UI chrome (help, command palette) + +pub mod layout; +pub mod overlay; +pub mod screen; +pub mod terminal; + +pub mod effects; + +pub use screen::*; +pub use terminal::*; diff --git a/src/ui/overlay/help/mod.rs b/src/ui/overlay/help/mod.rs new file mode 100644 index 0000000..992b837 --- /dev/null +++ b/src/ui/overlay/help/mod.rs @@ -0,0 +1,85 @@ +//! Help widget: keybinding reference overlay. +//! +//! Built from the *live* `Keybindings` (config-driven), so remapped keys are +//! always reflected here. + +use crate::state::AppState; +use ratatui::prelude::*; +use ratatui::symbols::border; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::Frame; +use xtop_widget_api::glyph::to_color; + +pub fn render(f: &mut Frame, state: &AppState, area: Rect) { + let fg = to_color(*state.current_theme.fg()); + let bg = to_color(*state.current_theme.bg()); + let accent = to_color(state.current_theme.palette[6]); + let kb = &state.keybindings; + + let mut text = vec![ + Line::from(""), + Line::from(vec![Span::styled( + " Keybindings", + Style::default().add_modifier(Modifier::BOLD), + )]), + Line::from(""), + Line::from(" ─────────────────────────────────────────────"), + ]; + push_key(&mut text, "Quit", &kb.quit, accent); + push_key(&mut text, "Help", &kb.help, accent); + text.push(Line::from("")); + push_key(&mut text, "Next theme", &kb.next_theme, accent); + push_key(&mut text, "Previous theme", &kb.prev_theme, accent); + push_key(&mut text, "Next layout", &kb.next_layout, accent); + push_key( + &mut text, + "Toggle fullscreen", + &kb.toggle_fullscreen, + accent, + ); + push_key(&mut text, "Cycle fullscreen", &kb.cycle_fullscreen, accent); + text.push(Line::from("")); + push_key(&mut text, "Search processes", &kb.search, accent); + push_key(&mut text, "Command palette", &kb.command_palette, accent); + push_key(&mut text, "Cancel", &kb.cancel, accent); + text.push(Line::from("")); + push_key(&mut text, "Kill process", &kb.kill_process, accent); + push_key(&mut text, "Select up", &kb.process_up, accent); + push_key(&mut text, "Select down", &kb.process_down, accent); + push_key(&mut text, "Cycle sort", &kb.cycle_sort, accent); + text.extend([ + Line::from(""), + Line::from(" ─────────────────────────────────────────────"), + Line::from(""), + Line::from(" Layout modes:"), + Line::from(format!(" Current: {}", state.current_layout_name())), + Line::from(" Dashboard | Vertical | Horizontal | CPU Focus"), + Line::from(" Memory Focus | Network Focus | Process Focus"), + Line::from(" + custom layouts from ~/.config/xtop/layouts/"), + Line::from(""), + Line::from(" https://github.com/xtop-cli/xtop"), + ]); + + let block = Block::default() + .title("Help") + .borders(Borders::ALL) + .border_set(border::DOUBLE) + .style(Style::default().fg(fg).bg(bg)); + let p = Paragraph::new(text) + .block(block) + .style(Style::default().fg(fg).bg(bg)) + .wrap(Wrap { trim: false }); + f.render_widget(p, area); +} + +fn push_key(text: &mut Vec>, action: &str, keys: &[String], accent: Color) { + let rendered = if keys.is_empty() { + "(unbound)".to_string() + } else { + keys.join(", ") + }; + text.push(Line::from(vec![ + Span::styled(format!(" {rendered:<12}"), Style::default().fg(accent)), + Span::raw(action.to_string()), + ])); +} diff --git a/src/ui/overlay/mod.rs b/src/ui/overlay/mod.rs new file mode 100644 index 0000000..6c3a8e4 --- /dev/null +++ b/src/ui/overlay/mod.rs @@ -0,0 +1,7 @@ +//! Kernel overlay widgets (help screen and command palette). +//! +//! These are UI chrome owned by the kernel — unlike the data widgets, which +//! live in external packs (`xtop-widgets` and friends). + +pub mod help; +pub mod palette; diff --git a/crates/xtop-tui/src/render/palette.rs b/src/ui/overlay/palette/mod.rs similarity index 89% rename from crates/xtop-tui/src/render/palette.rs rename to src/ui/overlay/palette/mod.rs index e1f1f11..a3aa1dd 100644 --- a/crates/xtop-tui/src/render/palette.rs +++ b/src/ui/overlay/palette/mod.rs @@ -1,13 +1,15 @@ -use crate::color::to_color; +//! Command palette widget: themes/layouts quick selection. + +use crate::state::{AppState, PalettePage}; use ratatui::prelude::*; use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; use ratatui::Frame; -use xtop_core::application::state::{AppState, PalettePage}; +use xtop_widget_api::glyph::to_color; pub fn render(f: &mut Frame, state: &AppState, area: Rect) { - let fg = to_color(state.current_theme.fg()); - let bg = to_color(state.current_theme.bg()); - let accent = to_color(&state.current_theme.palette[6]); + let fg = to_color(*state.current_theme.fg()); + let bg = to_color(*state.current_theme.bg()); + let accent = to_color(state.current_theme.palette[6]); let popup_width = (area.width as f64 * 0.6).min(60.0) as u16; let popup_height = (area.height as f64 * 0.6).min(30.0) as u16; diff --git a/crates/xtop-tui/src/render/mod.rs b/src/ui/screen.rs similarity index 52% rename from crates/xtop-tui/src/render/mod.rs rename to src/ui/screen.rs index f98841c..be4b844 100644 --- a/crates/xtop-tui/src/render/mod.rs +++ b/src/ui/screen.rs @@ -1,38 +1,33 @@ -mod battery; -mod cpu; -mod disk_io; -mod gpu; -mod header; -mod help; -mod memory; -mod network; -mod palette; -mod processes; -mod storage; - -mod layout_engine; - -use crate::color::to_color; -use layout_engine::{default_widgets, render_layout, WidgetFn}; +//! Screen renderer: dispatches to layouts, fullscreen, overlays and the +//! minimal view. Data widgets are drawn through the widget packs resolved by +//! the engine; overlays (help/palette) are kernel-owned. + +use crate::state::{AppState, FullScreenWidget, InputMode}; +use crate::ui::layout::{render_layout, render_named}; +use crate::ui::overlay::{help, palette}; use ratatui::prelude::*; use ratatui::Frame; use std::collections::HashMap; -use std::sync::OnceLock; -use xtop_core::application::state::{ - detect_effective_layout, AppState, EffectiveLayout, FullScreenWidget, InputMode, -}; - -/// Built-in widgets (lazily initialized). -fn widgets() -> &'static HashMap<&'static str, WidgetFn> { - static WIDGETS: OnceLock> = OnceLock::new(); - WIDGETS.get_or_init(default_widgets) -} +use xtop_layout::{detect_effective_layout, EffectiveLayout}; +use xtop_plugin_api::PluginWidget; +use xtop_widget_api::glyph::to_color; /// Build a plugin widget lookup map from AppState. -fn plugin_widgets(state: &AppState) -> HashMap { - let mut map: HashMap = HashMap::new(); +/// +/// Plugin renderers only see [`HostState`](xtop_plugin_api::HostState); +/// they keep precedence over every pack. +fn plugin_widgets(state: &AppState) -> HashMap { + let mut map: HashMap = HashMap::new(); for reg in &state.plugin_widgets { - map.insert(reg.name.clone(), reg.render.clone()); + // `PluginWidget` is not `Clone` by contract; the render closure + // (an `Arc`) is, so rebuild a lightweight copy per frame. + map.insert( + reg.name.clone(), + PluginWidget { + name: reg.name.clone(), + render: reg.render.clone(), + }, + ); } map } @@ -62,7 +57,7 @@ pub fn render(f: &mut Frame, state: &AppState) { render_minimal(f, state, area); } else { let def = state.current_layout(); - render_layout(f, state, area, def, widgets(), &pw); + render_layout(f, state, area, def, &pw); } if state.input_mode == InputMode::Searching { @@ -74,15 +69,15 @@ pub fn render(f: &mut Frame, state: &AppState) { fn render_too_small(f: &mut Frame, state: &AppState, area: Rect) { use ratatui::widgets::Paragraph; - let fg = to_color(state.current_theme.fg()); + let fg = to_color(*state.current_theme.fg()); let text = Paragraph::new("Terminal too small\nMinimum: 40x8").style(Style::default().fg(fg)); f.render_widget(text, area); } fn render_search_overlay(f: &mut Frame, state: &AppState, area: Rect) { use ratatui::widgets::{Block, Borders, Paragraph}; - let fg = to_color(state.current_theme.fg()); - let bg = to_color(state.current_theme.bg()); + let fg = to_color(*state.current_theme.fg()); + let bg = to_color(*state.current_theme.bg()); let search_text = format!("/{}_", state.search_query); let overlay = Paragraph::new(search_text) .style(Style::default().fg(fg).bg(bg)) @@ -101,42 +96,51 @@ fn render_search_overlay(f: &mut Frame, state: &AppState, area: Rect) { } fn render_fullscreen(f: &mut Frame, state: &AppState, area: Rect) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(3), Constraint::Min(0)]) - .split(area); - header::render(f, state, chunks[0]); - match state.full_screen_widget { - FullScreenWidget::Cpu => cpu::render(f, state, chunks[1]), - FullScreenWidget::Memory => memory::render(f, state, chunks[1]), - FullScreenWidget::Storage => storage::render(f, state, chunks[1]), - FullScreenWidget::Network => network::render(f, state, chunks[1]), - FullScreenWidget::Processes => processes::render(f, state, chunks[1]), - FullScreenWidget::DiskIO => disk_io::render(f, state, chunks[1]), - FullScreenWidget::Gpu => gpu::render(f, state, chunks[1]), - FullScreenWidget::Battery => battery::render(f, state, chunks[1]), - FullScreenWidget::None => {} + let chunks = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).split(area); + let pw = plugin_widgets(state); + render_named(f, state, "header", chunks[0], &pw); + let name = fullscreen_widget_name(state.full_screen_widget); + if !render_named(f, state, name, chunks[1], &pw) { + let text = format!("No widget registered for '{name}'"); + let fg = to_color(*state.current_theme.fg()); + let p = ratatui::widgets::Paragraph::new(text).style(Style::default().fg(fg)); + f.render_widget(p, chunks[1]); + } +} + +fn fullscreen_widget_name(w: FullScreenWidget) -> &'static str { + match w { + FullScreenWidget::Cpu => "cpu", + FullScreenWidget::Memory => "memory", + FullScreenWidget::Storage => "storage", + FullScreenWidget::Network => "network", + FullScreenWidget::Processes => "processes", + FullScreenWidget::DiskIO => "disk_io", + FullScreenWidget::Gpu => "gpu", + FullScreenWidget::Battery => "battery", + FullScreenWidget::None => "cpu", } } fn render_minimal(f: &mut Frame, state: &AppState, area: Rect) { use ratatui::widgets::Gauge; - let bg = to_color(state.current_theme.bg()); + let bg = to_color(*state.current_theme.bg()); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), - Constraint::Length(2), - Constraint::Length(2), - Constraint::Min(0), - ]) - .split(area); + let chunks = Layout::vertical([ + Constraint::Length(3), + Constraint::Length(2), + Constraint::Length(2), + Constraint::Min(0), + ]) + .split(area); - header::render(f, state, chunks[0]); + let pw = plugin_widgets(state); + render_named(f, state, "header", chunks[0], &pw); - let snap = state.snapshot(); + let Some(snap) = state.snapshot_cache() else { + return; + }; let cpu_pct = snap.cpus.first().map(|c| c.usage).unwrap_or(0.0); let cpu_text = format!( "CPU: {:>3.0}% | Mem: {:.1}/{:.1}G ({:>3.0}%)", @@ -148,7 +152,7 @@ fn render_minimal(f: &mut Frame, state: &AppState, area: Rect) { let cpu_gauge = Gauge::default() .gauge_style( Style::default() - .fg(to_color(&state.current_theme.palette[1])) + .fg(to_color(state.current_theme.palette[1])) .bg(bg), ) .percent(cpu_pct as u16) @@ -165,12 +169,12 @@ fn render_minimal(f: &mut Frame, state: &AppState, area: Rect) { let mem_gauge = Gauge::default() .gauge_style( Style::default() - .fg(to_color(&state.current_theme.palette[2])) + .fg(to_color(state.current_theme.palette[2])) .bg(bg), ) .percent(mem_pct) .label(mem_text); f.render_widget(mem_gauge, chunks[2]); - processes::render(f, state, chunks[3]); + render_named(f, state, "processes", chunks[3], &pw); } diff --git a/crates/xtop-tui/src/terminal.rs b/src/ui/terminal.rs similarity index 60% rename from crates/xtop-tui/src/terminal.rs rename to src/ui/terminal.rs index 37df6e7..22ab9b5 100644 --- a/crates/xtop-tui/src/terminal.rs +++ b/src/ui/terminal.rs @@ -1,5 +1,6 @@ +//! Terminal lifecycle: raw mode, alternate screen, mouse and paste capture. use crossterm::{ - event::{DisableMouseCapture, EnableMouseCapture}, + event::{DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; @@ -10,13 +11,23 @@ use std::panic; pub type Tui = Terminal>; pub fn init() -> io::Result { - execute!(io::stdout(), EnterAlternateScreen, EnableMouseCapture)?; + execute!( + io::stdout(), + EnterAlternateScreen, + EnableMouseCapture, + EnableBracketedPaste + )?; enable_raw_mode()?; Terminal::new(CrosstermBackend::new(io::stdout())) } pub fn restore() -> io::Result<()> { - execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture)?; + execute!( + io::stdout(), + LeaveAlternateScreen, + DisableMouseCapture, + DisableBracketedPaste + )?; disable_raw_mode()?; Ok(()) }