diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2efbdb9..a3a18db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,11 +8,18 @@ on: jobs: check: - runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo clippy -- -D warnings - run: cargo test + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check advisories bans licenses sources - run: cargo build --release diff --git a/.gitignore b/.gitignore index efcd773..c21551e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ -/target +/target/ +**/*.rs.bk +*.swp +*.swo +*~ .DS_Store -.agent/ -.claude/worktrees/ -demo.tape -.idea/ \ No newline at end of file +Thumbs.db diff --git a/Cargo.lock b/Cargo.lock index 0e38512..56358ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,18 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -156,14 +168,38 @@ dependencies = [ "winapi", ] +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", ] [[package]] @@ -179,13 +215,24 @@ dependencies = [ "syn", ] +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core 0.20.11", + "quote", + "syn", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn", ] @@ -233,6 +280,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -245,6 +304,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + [[package]] name = "foldhash" version = "0.1.5" @@ -275,6 +340,15 @@ dependencies = [ "wasip3", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.5" @@ -292,6 +366,15 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -361,7 +444,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "darling", + "darling 0.23.0", "indoc", "proc-macro2", "quote", @@ -405,6 +488,16 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libredox" version = "0.1.15" @@ -414,6 +507,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -434,8 +538,10 @@ dependencies = [ "crossterm", "dirs", "libc", + "nvml-wrapper", "proc_pidinfo", "ratatui", + "rusqlite", "serde", "serde_json", "sysinfo", @@ -502,6 +608,29 @@ dependencies = [ "autocfg", ] +[[package]] +name = "nvml-wrapper" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9bff0aa1d48904a1385ea2a8b97576fbdcbc9a3cfccd0d31fe978e1c4038c5" +dependencies = [ + "bitflags", + "libloading", + "nvml-wrapper-sys", + "static_assertions", + "thiserror 1.0.69", + "wrapcenum-derive", +] + +[[package]] +name = "nvml-wrapper-sys" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "698d45156f28781a4e79652b6ebe2eaa0589057d588d3aec1333f6466f13fcb5" +dependencies = [ + "libloading", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -543,6 +672,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + [[package]] name = "prettyplease" version = "0.2.37" @@ -644,7 +779,21 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror", + "thiserror 2.0.18", +] + +[[package]] +name = "rusqlite" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b838eba278d213a8beaf485bd313fd580ca4505a00d5871caeb1457c55322cae" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", ] [[package]] @@ -855,13 +1004,33 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -916,6 +1085,18 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1323,6 +1504,38 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wrapcenum-derive" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a76ff259533532054cfbaefb115c613203c73707017459206380f03b3b3f266e" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/Cargo.toml b/Cargo.toml index 4b2e402..94ad36d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,6 +19,10 @@ path = "src/main.rs" name = "llmtop" path = "src/lib.rs" +[features] +default = [] +nvidia = ["nvml-wrapper"] + [dependencies] ratatui = "0.29" crossterm = "0.28" @@ -26,7 +30,9 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" dirs = "6" chrono = { version = "0.4", features = ["serde"] } +rusqlite = { version = "0.31", features = ["bundled"] } tempfile = "3" +nvml-wrapper = { version = "0.10", optional = true } [target.'cfg(target_vendor = "apple")'.dependencies] proc_pidinfo = "0.1" diff --git a/LICENSE b/LICENSE index 7922287..980910c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,674 @@ -MIT License - -Copyright (c) 2026 Tae Hwan Jung - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear direction next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a computer network, will apply +to the combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md index 880aec3..e9d0f9c 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # LLMtop +[![CI](https://github.com/weby-homelab/LLMtop/actions/workflows/ci.yml/badge.svg)](https://github.com/weby-homelab/LLMtop/actions/workflows/ci.yml) +[![License: GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE) +[![Rust](https://img.shields.io/badge/rust-1.88%2B-orange.svg)](https://www.rust-lang.org/) + **Like [btop](https://github.com/aristocratos/btop), but for your local LLMs, runners, and AI coding agents.** See every active local LLM session, model usage, context window, rate limits, child processes, open ports, and more at a glance. Supports Ollama, llama.cpp, vLLM, OpenCode, Odysseus, and any OpenAI-compatible server (LM Studio, LiteLLM, Open WebUI, KoboldCpp, TabbyAPI, etc.). diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..1afec1f --- /dev/null +++ b/deny.toml @@ -0,0 +1,26 @@ +[advisories] +version = 2 +ignore = [] + +[licenses] +version = 2 +allow = [ + "MIT", + "Apache-2.0", + "GPL-3.0", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Zlib", + "MPL-2.0", + "Unicode-3.0", + "Unicode-DFS-2016", +] + +[bans] +multiple-versions = "warn" +wildcards = "deny" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" diff --git a/src/app.rs b/src/app.rs index 6d8eb37..2ba2e69 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1,4 +1,6 @@ use crate::collector::{read_rate_limits, McpServer, MultiCollector}; +use crate::collector::rate_limit::read_opencode_rate_limits; +use crate::gpu::{GpuMetrics, GpuSampler}; use crate::host_info::{AgentAggregate, HostMetrics, HostSampler}; use crate::model::{AgentSession, OrphanPort, RateLimitInfo, SessionStatus}; use crate::theme::Theme; @@ -68,17 +70,19 @@ pub enum NarrowSection { Sessions, Projects, Context, + Quota, Tokens, Ports, Mcp, + Gpu, } impl NarrowSection { pub fn tab(self) -> NarrowTab { match self { Self::Sessions | Self::Projects => NarrowTab::Work, - Self::Context | Self::Tokens => NarrowTab::Usage, - Self::Ports | Self::Mcp => NarrowTab::System, + Self::Context | Self::Quota | Self::Tokens => NarrowTab::Usage, + Self::Ports | Self::Mcp | Self::Gpu => NarrowTab::System, } } } @@ -114,11 +118,13 @@ pub struct App { kill_confirm: Option<(usize, Instant)>, pub theme: Theme, pub show_context: bool, + pub show_quota: bool, pub show_tokens: bool, pub show_projects: bool, pub show_ports: bool, pub show_sessions: bool, pub show_mcp: bool, + pub show_gpu: bool, pub narrow_tab: NarrowTab, pub active_narrow_section: Option, pub maximized_narrow_section: Option, @@ -141,6 +147,8 @@ pub struct App { host_sampler: HostSampler, /// Latest host metrics snapshot (None until first valid sample). pub host_metrics: Option, + pub gpu_metrics: Vec, + gpu_sampler: GpuSampler, /// Aggregate metrics across all sessions (recomputed each tick). pub agent_aggregate: AgentAggregate, /// Help overlay (`?`) visibility. @@ -189,11 +197,13 @@ impl App { kill_confirm: None, theme, show_context: panels.context, + show_quota: panels.quota, show_tokens: panels.tokens, show_projects: panels.projects, show_ports: panels.ports, show_sessions: panels.sessions, show_mcp: panels.mcp, + show_gpu: panels.gpu, narrow_tab: NarrowTab::Work, active_narrow_section: Some(NarrowSection::Sessions), maximized_narrow_section: None, @@ -209,6 +219,8 @@ impl App { show_file_audit: false, host_sampler: HostSampler::new(), host_metrics: None, + gpu_metrics: Vec::new(), + gpu_sampler: GpuSampler::new(), agent_aggregate: AgentAggregate::default(), help_open: false, view_open: false, @@ -232,11 +244,13 @@ impl App { pub fn toggle_panel(&mut self, panel: u8) { match panel { 1 => self.show_context = !self.show_context, - 2 => self.show_tokens = !self.show_tokens, - 3 => self.show_projects = !self.show_projects, - 4 => self.show_ports = !self.show_ports, - 5 => self.show_sessions = !self.show_sessions, - 6 => self.show_mcp = !self.show_mcp, + 2 => self.show_quota = !self.show_quota, + 3 => self.show_tokens = !self.show_tokens, + 4 => self.show_projects = !self.show_projects, + 5 => self.show_ports = !self.show_ports, + 6 => self.show_sessions = !self.show_sessions, + 7 => self.show_mcp = !self.show_mcp, + 8 => self.show_gpu = !self.show_gpu, _ => return, } self.persist_panel_visibility(); @@ -260,11 +274,13 @@ impl App { fn persist_panel_visibility(&mut self) { let panels = crate::config::PanelVisibility { context: self.show_context, + quota: self.show_quota, tokens: self.show_tokens, projects: self.show_projects, ports: self.show_ports, sessions: self.show_sessions, mcp: self.show_mcp, + gpu: self.show_gpu, }; if let Err(e) = crate::config::save_panel_visibility(&panels) { self.set_status(format!("panels save failed: {}", e)); @@ -283,7 +299,7 @@ impl App { } pub fn config_item_count(&self) -> usize { - 8 // theme + 7 panel toggles + 9 } pub fn config_select_next(&mut self) { @@ -303,11 +319,13 @@ impl App { return; } 1 => self.show_context = !self.show_context, - 2 => self.show_tokens = !self.show_tokens, - 3 => self.show_projects = !self.show_projects, - 4 => self.show_ports = !self.show_ports, - 5 => self.show_sessions = !self.show_sessions, - 6 => self.show_mcp = !self.show_mcp, + 2 => self.show_quota = !self.show_quota, + 3 => self.show_tokens = !self.show_tokens, + 4 => self.show_projects = !self.show_projects, + 5 => self.show_ports = !self.show_ports, + 6 => self.show_sessions = !self.show_sessions, + 7 => self.show_mcp = !self.show_mcp, + 8 => self.show_gpu = !self.show_gpu, _ => return, } self.persist_panel_visibility(); @@ -317,8 +335,8 @@ impl App { pub fn narrow_tab_visible(&self, tab: NarrowTab) -> bool { match tab { NarrowTab::Work => self.show_sessions || self.show_projects, - NarrowTab::Usage => self.show_context || self.show_tokens, - NarrowTab::System => self.show_ports || self.show_mcp, + NarrowTab::Usage => self.show_context || self.show_quota || self.show_tokens, + NarrowTab::System => self.show_ports || self.show_mcp || self.show_gpu, } } @@ -380,9 +398,11 @@ impl App { NarrowSection::Sessions => self.show_sessions, NarrowSection::Projects => self.show_projects, NarrowSection::Context => self.show_context, + NarrowSection::Quota => self.show_quota, NarrowSection::Tokens => self.show_tokens, NarrowSection::Ports => self.show_ports, NarrowSection::Mcp => self.show_mcp, + NarrowSection::Gpu => self.show_gpu, } } @@ -391,9 +411,10 @@ impl App { NarrowTab::Work => &[NarrowSection::Sessions, NarrowSection::Projects], NarrowTab::Usage => &[ NarrowSection::Context, + NarrowSection::Quota, NarrowSection::Tokens, ], - NarrowTab::System => &[NarrowSection::Ports, NarrowSection::Mcp], + NarrowTab::System => &[NarrowSection::Ports, NarrowSection::Mcp, NarrowSection::Gpu], }; sections .iter() @@ -483,6 +504,45 @@ impl App { self.status_msg = Some((msg, Instant::now())); } + pub fn check_alerts(&self) -> Vec { + let mut alerts = Vec::new(); + + for s in &self.sessions { + if s.context_percent > 90.0 { + alerts.push(format!( + "{}: context at {:.0}%", + s.project_name, s.context_percent + )); + } + } + + for rl in &self.rate_limits { + if rl.five_hour_pct.is_some_and(|p| p > 80.0) { + alerts.push(format!( + "{}: 5h rate limit at {:.0}%", + rl.source.to_uppercase(), + rl.five_hour_pct.unwrap() + )); + } + if rl.seven_day_pct.is_some_and(|p| p > 80.0) { + alerts.push(format!( + "{}: 7d rate limit at {:.0}%", + rl.source.to_uppercase(), + rl.seven_day_pct.unwrap() + )); + } + } + + if !self.orphan_ports.is_empty() { + alerts.push(format!( + "{} orphan port(s) detected", + self.orphan_ports.len() + )); + } + + alerts + } + /// Full refresh used by the TUI: collect monitored data, then generate and /// retry session summaries. Equivalent to [`App::tick_no_summaries`] followed /// by [`App::drain_and_retry_summaries`]. @@ -503,6 +563,7 @@ impl App { self.orphan_ports = self.collector.orphan_ports.clone(); self.mcp_servers = self.collector.mcp_servers.clone(); self.host_metrics = self.host_sampler.sample(); + self.gpu_metrics = self.gpu_sampler.sample(); self.agent_aggregate = AgentAggregate::from_sessions(&self.sessions); if self.selected >= self.sessions.len() && !self.sessions.is_empty() { self.selected = self.sessions.len() - 1; @@ -532,13 +593,20 @@ impl App { self.rate_limit_counter = 0; let extra_dirs = self.collector.all_config_dirs(); self.rate_limits = read_rate_limits(&extra_dirs); - // Merge live rate limits from agent collectors (e.g. Codex JSONL parsing) self.rate_limits.extend(self.collector.agent_rate_limits()); + self.rate_limits.extend(read_opencode_rate_limits()); } else { self.rate_limit_counter += 1; } promote_waiting_to_rate_limited(&mut self.sessions, &self.rate_limits); + + if self.status_msg.is_none() { + let alerts = self.check_alerts(); + if let Some(first) = alerts.first() { + self.set_status(first.clone()); + } + } } /// Drain completed summary results and spawn retries. Does NOT recollect @@ -727,8 +795,18 @@ impl App { return; } let _ = std::process::Command::new("kill") - .args(["-9", &pid.to_string()]) + .args(["-15", &pid.to_string()]) .output(); + std::thread::sleep(std::time::Duration::from_secs(3)); + if std::process::Command::new("kill") + .args(["-0", &pid.to_string()]) + .status() + .is_ok_and(|s| s.success()) + { + let _ = std::process::Command::new("kill") + .args(["-9", &pid.to_string()]) + .output(); + } self.tick(); return; } @@ -955,7 +1033,16 @@ fn load_summary_cache() -> HashMap { fn save_summary_cache(summaries: &HashMap) { let path = cache_path(); let _ = std::fs::create_dir_all(cache_dir()); - if let Ok(json) = serde_json::to_string(summaries) { + let mut bounded = summaries.clone(); + const MAX_CACHED_SUMMARIES: usize = 500; + while bounded.len() > MAX_CACHED_SUMMARIES { + if let Some(oldest_key) = bounded.keys().next().cloned() { + bounded.remove(&oldest_key); + } else { + break; + } + } + if let Ok(json) = serde_json::to_string(&bounded) { let tmp = path.with_extension("tmp"); if std::fs::write(&tmp, &json).is_ok() { let _ = std::fs::rename(&tmp, &path); diff --git a/src/collector/auto_discover.rs b/src/collector/auto_discover.rs index 15402f8..35f576a 100644 --- a/src/collector/auto_discover.rs +++ b/src/collector/auto_discover.rs @@ -195,46 +195,53 @@ impl AgentCollector for AutoDiscoverCollector { port_pids.dedup_by_key(|pp| pp.0); let mut discovered = Vec::new(); + let chunk_size = 10; + + for chunk_start in (0..port_pids.len()).step_by(chunk_size) { + let chunk_end = (chunk_start + chunk_size).min(port_pids.len()); + let chunk = &port_pids[chunk_start..chunk_end]; + + let results: Vec<_> = std::thread::scope(|s| { + let handles: Vec<_> = chunk + .iter() + .map(|&(port, pid)| { + s.spawn(move || { + let addr = format!("127.0.0.1:{}", port); + let raw = raw_http_get(&addr, "/v1/models")?; + if !raw.starts_with("HTTP/1.") || !raw.contains("200") { + return None; + } + let body = extract_body(&raw)?; + let models = parse_model_ids(body); + if models.is_empty() { + return None; + } + let server_name = identify_server(&addr, body); + Some((port, pid, server_name, models)) + }) + }) + .collect(); + + handles + .into_iter() + .filter_map(|h| h.join().ok().flatten()) + .collect() + }); - for (port, pid) in port_pids { - let addr = format!("127.0.0.1:{}", port); - - // Probe /v1/models - let raw = match raw_http_get(&addr, "/v1/models") { - Some(r) => r, - None => continue, - }; - - // Must be a successful HTTP response - if !raw.starts_with("HTTP/1.") || !raw.contains("200") { - continue; - } - - let body = match extract_body(&raw) { - Some(b) => b, - None => continue, - }; - - let models = parse_model_ids(body); - if models.is_empty() { - continue; + for (port, pid, server_name, models) in results { + let rss_kb = shared + .process_info + .get(&pid) + .map(|p| p.rss_kb) + .unwrap_or(0); + discovered.push(DiscoveredServer { + port, + pid, + server_name, + models, + rss_kb, + }); } - - let server_name = identify_server(&addr, body); - - let rss_kb = shared - .process_info - .get(&pid) - .map(|p| p.rss_kb) - .unwrap_or(0); - - discovered.push(DiscoveredServer { - port, - pid, - server_name, - models, - rss_kb, - }); } self.cached_servers = discovered; @@ -274,7 +281,7 @@ impl AutoDiscoverCollector { started_at: now_ms, status, model: model_str, - effort: format!(":{}", server.port), + effort: String::new(), context_percent: 0.0, total_input_tokens: 0, total_output_tokens: 0, diff --git a/src/collector/mcp.rs b/src/collector/mcp.rs index 0222092..aaf0102 100644 --- a/src/collector/mcp.rs +++ b/src/collector/mcp.rs @@ -2,10 +2,8 @@ use super::process::{self, ProcInfo}; use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; #[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] -use std::process::{Command, Stdio}; -#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] -use std::time::Instant; -use std::time::{Duration, SystemTime}; +use std::process::Command; +use std::time::SystemTime; /// Active-thread mtime threshold: a rollout written within the last 30 minutes /// ACTIVE_MTIME_SECS counts as "active". File-descriptor presence alone @@ -272,92 +270,6 @@ pub(crate) fn map_pid_to_rollouts(pids: &[u32]) -> HashMap> { map } -#[allow(dead_code)] -pub(crate) fn map_pid_to_rollouts_with_timeout_and_pid_slot( - pids: &[u32], - timeout: Duration, - child_pid_slot: Option>, -) -> Option>> { - if pids.is_empty() { - return Some(HashMap::new()); - } - - #[cfg(any(target_os = "linux", target_os = "windows"))] - { - let _ = (timeout, child_pid_slot); - Some(map_pid_to_rollouts(pids)) - } - - #[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] - { - let pid_args: Vec = pids.iter().map(|p| format!("-p{}", p)).collect(); - let mut args = vec!["-F", "pn"]; - for pa in &pid_args { - args.push(pa); - } - - let output_file = tempfile::NamedTempFile::new().ok()?; - let output_for_child = output_file.reopen().ok()?; - let mut child = match Command::new("lsof") - .args(&args) - .stdout(Stdio::from(output_for_child)) - .stderr(Stdio::null()) - .spawn() - { - Ok(child) => child, - Err(_) => return None, - }; - let child_pid = child.id(); - if let Some(slot) = &child_pid_slot { - slot.store(child_pid, std::sync::atomic::Ordering::SeqCst); - } - - let started = Instant::now(); - loop { - match child.try_wait() { - Ok(Some(_)) => { - if let Some(slot) = &child_pid_slot { - slot.store(0, std::sync::atomic::Ordering::SeqCst); - } - let stdout = std::fs::read_to_string(output_file.path()).ok(); - return stdout.map(|s| parse_lsof_rollout_output(&s)); - } - Ok(None) if started.elapsed() >= timeout => { - if let Some(slot) = &child_pid_slot { - slot.store(0, std::sync::atomic::Ordering::SeqCst); - } - let _ = Command::new("kill") - .args(["-9", &child_pid.to_string()]) - .status(); - let _ = child.wait(); - return None; - } - Ok(None) => std::thread::sleep(Duration::from_millis(100)), - Err(_) => { - if let Some(slot) = &child_pid_slot { - slot.store(0, std::sync::atomic::Ordering::SeqCst); - } - return None; - } - } - } - } -} - -#[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] -fn kill_pid(pid: u32) { - if pid != 0 { - let _ = Command::new("kill").args(["-9", &pid.to_string()]).status(); - } -} - -#[cfg(any(target_os = "linux", target_os = "windows"))] -fn kill_pid(_pid: u32) {} - -pub(crate) fn kill_rollout_scan_child(pid: u32) { - kill_pid(pid); -} - #[cfg(any( test, all(not(target_os = "linux"), not(target_os = "windows")) diff --git a/src/collector/mod.rs b/src/collector/mod.rs index 4bcb8e0..5ee2768 100644 --- a/src/collector/mod.rs +++ b/src/collector/mod.rs @@ -88,15 +88,19 @@ pub(crate) fn sanitize_terminal_text(s: &str) -> String { .collect() } +pub(crate) fn truncate_at_char_boundary(s: &mut String, max_bytes: usize) { + if s.len() > max_bytes { + let mut end = max_bytes; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + s.truncate(end); + } +} + use crate::model::{AgentSession, OrphanPort, RateLimitInfo, SessionStatus}; use std::collections::{HashMap, HashSet}; use std::path::PathBuf; -use std::sync::{ - atomic::{AtomicU32, Ordering}, - mpsc::{self, Receiver, Sender}, - Arc, -}; -use std::time::{Duration, Instant}; /// Trait for agent-specific session collectors. /// Implement this to add support for a new AI coding agent. @@ -139,9 +143,6 @@ pub struct SharedProcessData { /// sessions panel restores upstream behavior. Driven by the user /// toggle (Shift+M). pub mcp_suppress: bool, - /// Cached Desktop app-server PID -> open rollout files. This is populated - /// by a background scanner so slow macOS lsof calls cannot block the TUI. - pub desktop_rollout_fd_map: HashMap>, } /// Context window size for this model (e.g. 200K, 1M). @@ -177,7 +178,6 @@ impl SharedProcessData { mcp_server_pids: HashSet::new(), mcp_owned_rollouts: HashSet::new(), mcp_suppress: true, - desktop_rollout_fd_map: HashMap::new(), } } } @@ -190,114 +190,9 @@ struct TrackedPortChild { project_name: String, } -#[allow(dead_code)] -struct DesktopRolloutScanResult { - pids: Vec, - rollouts: Option>>, -} - -#[allow(dead_code)] -struct DesktopRolloutScanner { - cached: HashMap>, - cached_pids: Vec, - in_flight_pids: Option>, - child_pid: Arc, - last_started: Option, - tx: Sender, - rx: Receiver, -} - -#[allow(dead_code)] -const DESKTOP_ROLLOUT_SCAN_TIMEOUT: Duration = Duration::from_secs(90); -#[allow(dead_code)] -const DESKTOP_ROLLOUT_RESCAN_INTERVAL: Duration = Duration::from_secs(60); - -#[allow(dead_code)] -impl DesktopRolloutScanner { - fn new() -> Self { - let (tx, rx) = mpsc::channel(); - Self { - cached: HashMap::new(), - cached_pids: Vec::new(), - in_flight_pids: None, - child_pid: Arc::new(AtomicU32::new(0)), - last_started: None, - tx, - rx, - } - } - - fn update(&mut self, pids: &[u32]) -> HashMap> { - self.poll_completed(); - if self.should_start(pids) { - self.start(pids.to_vec()); - } - self.cached_for(pids) - } - - fn poll_completed(&mut self) { - while let Ok(result) = self.rx.try_recv() { - self.apply_result(result); - } - } - - fn apply_result(&mut self, result: DesktopRolloutScanResult) { - self.in_flight_pids = None; - if let Some(rollouts) = result.rollouts { - self.cached_pids = result.pids; - self.cached = rollouts; - } - } - - fn should_start(&self, pids: &[u32]) -> bool { - if pids.is_empty() || self.in_flight_pids.is_some() { - return false; - } - if self.cached_pids != pids { - return true; - } - self.last_started - .is_none_or(|started| started.elapsed() >= DESKTOP_ROLLOUT_RESCAN_INTERVAL) - } - - fn start(&mut self, pids: Vec) { - self.in_flight_pids = Some(pids.clone()); - self.last_started = Some(Instant::now()); - let tx = self.tx.clone(); - let child_pid = self.child_pid.clone(); - std::thread::spawn(move || { - let rollouts = mcp::map_pid_to_rollouts_with_timeout_and_pid_slot( - &pids, - DESKTOP_ROLLOUT_SCAN_TIMEOUT, - Some(child_pid), - ); - let _ = tx.send(DesktopRolloutScanResult { pids, rollouts }); - }); - } - - fn cached_for(&self, pids: &[u32]) -> HashMap> { - let mut map = HashMap::new(); - for pid in pids { - if let Some(paths) = self.cached.get(pid) { - map.insert(*pid, paths.clone()); - } - } - map - } -} - -impl Drop for DesktopRolloutScanner { - fn drop(&mut self) { - let pid = self.child_pid.swap(0, Ordering::SeqCst); - mcp::kill_rollout_scan_child(pid); - } -} - /// Aggregates sessions from multiple collectors (Claude, Codex, etc.) pub struct MultiCollector { collectors: Vec>, - #[allow(dead_code)] - codex_enabled: bool, tick_count: u32, cached_ports: HashMap>, /// PID set snapshot from last port scan — invalidate cache when PIDs change. @@ -315,8 +210,6 @@ pub struct MultiCollector { /// behavior (mcp-server PIDs and their rollouts appear there too, /// with the existing 1-of-N HashMap-overwrite caveat). pub mcp_suppress: bool, - #[allow(dead_code)] - desktop_rollout_scanner: DesktopRolloutScanner, } /// How often to refresh expensive I/O (in ticks). 5 ticks × 2s = 10s. @@ -355,10 +248,8 @@ impl MultiCollector { if !is_hidden("auto") { collectors.push(Box::new(AutoDiscoverCollector::new())); } - let codex_enabled = false; Self { collectors, - codex_enabled, tick_count: SLOW_POLL_INTERVAL, // trigger on first tick cached_ports: HashMap::new(), cached_port_pids: Vec::new(), @@ -367,7 +258,6 @@ impl MultiCollector { orphan_ports: Vec::new(), mcp_servers: Vec::new(), mcp_suppress: true, - desktop_rollout_scanner: DesktopRolloutScanner::new(), } } @@ -556,30 +446,4 @@ mod tests { ]); assert!(mc.collectors.is_empty()); } - - #[test] - fn desktop_rollout_scanner_keeps_cache_on_failed_result() { - let mut scanner = DesktopRolloutScanner::new(); - let mut cached = HashMap::new(); - cached.insert(42, vec![PathBuf::from("/tmp/rollout-live.jsonl")]); - - scanner.apply_result(DesktopRolloutScanResult { - pids: vec![42], - rollouts: Some(cached.clone()), - }); - scanner.apply_result(DesktopRolloutScanResult { - pids: vec![42], - rollouts: None, - }); - - assert_eq!(scanner.cached_for(&[42]), cached); - } - - #[test] - fn desktop_rollout_scanner_in_flight_guard_blocks_duplicate_scan() { - let mut scanner = DesktopRolloutScanner::new(); - scanner.in_flight_pids = Some(vec![42]); - - assert!(!scanner.should_start(&[42])); - } } diff --git a/src/collector/opencode.rs b/src/collector/opencode.rs index 2207318..33060bf 100644 --- a/src/collector/opencode.rs +++ b/src/collector/opencode.rs @@ -1,9 +1,9 @@ use super::{process, context_window_for_model}; use crate::model::{AgentSession, ChildProcess, SessionStatus, ChatMessage, ToolCall, ChatRole}; -use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; +#[cfg(not(any(target_os = "linux", target_os = "windows")))] use std::process::Command; /// Maximum sessions to fetch from the DB per query. @@ -13,16 +13,14 @@ const MAX_SESSIONS: u32 = 50; /// /// Discovery strategy: /// 1. `ps` to find running opencode processes (from shared process data) -/// 2. Query SQLite DB at ~/.local/share/opencode/opencode.db via `sqlite3` CLI +/// 2. Query SQLite DB at ~/.local/share/opencode/opencode.db via `rusqlite` /// 3. Match running PIDs to sessions by cwd /// -/// Uses `sqlite3 -readonly -json` for safe concurrent reads (WAL mode). +/// Uses rusqlite with `SQLITE_OPEN_READ_ONLY` for safe concurrent reads (WAL mode). /// DB rows are cached and only refreshed on `shared.slow_tick` (every ~10s) /// OR when the database file/WAL modification time changes, enabling instant updates. pub struct OpenCodeCollector { db_path: PathBuf, - /// Whether sqlite3 CLI is available (checked once). - sqlite3_available: Option, /// Cached DB rows from the last slow-tick or mtime-change query. Reused on fast ticks. cached_db_sessions: Vec, /// Cached DB subagent rows from the last slow-tick query. @@ -35,9 +33,6 @@ pub struct OpenCodeCollector { last_db_mtime: Option, /// Last modification time of the WAL file. last_wal_mtime: Option, - /// Whether the "sqlite3 missing" warning has been emitted (once). - #[cfg(target_os = "windows")] - warned_sqlite3_missing: bool, } impl OpenCodeCollector { @@ -50,25 +45,13 @@ impl OpenCodeCollector { let db_path = windows_db_path(db_path); Self { db_path, - sqlite3_available: None, cached_db_sessions: Vec::new(), cached_db_subagents: Vec::new(), cached_chat_messages: HashMap::new(), cached_tool_calls: HashMap::new(), last_db_mtime: None, last_wal_mtime: None, - #[cfg(target_os = "windows")] - warned_sqlite3_missing: false, - } - } - - fn check_sqlite3(&mut self) -> bool { - if let Some(available) = self.sqlite3_available { - return available; } - let available = Command::new("sqlite3").arg("--version").output().is_ok(); - self.sqlite3_available = Some(available); - available } fn collect_sessions(&mut self, shared: &super::SharedProcessData) -> Vec { @@ -91,28 +74,7 @@ impl OpenCodeCollector { self.cached_tool_calls.clear(); return vec![]; } - if !self.check_sqlite3() { - // The DB exists but we can't read it: on Windows sqlite3 is - // usually not preinstalled, so say why sessions are missing - // instead of failing silently. - #[cfg(target_os = "windows")] - if !self.warned_sqlite3_missing { - self.warned_sqlite3_missing = true; - eprintln!( - "llmtop: OpenCode database found at {} but the `sqlite3` CLI is not on PATH; \ - OpenCode sessions will not appear. Install it (e.g. `winget install SQLite.SQLite`) \ - and restart llmtop.", - self.db_path.display() - ); - } - self.cached_db_sessions.clear(); - self.cached_db_subagents.clear(); - self.cached_chat_messages.clear(); - self.cached_tool_calls.clear(); - return vec![]; - } - // Find running opencode PIDs and their commands for cwd matching let opencode_pids = Self::find_opencode_pids(&shared.process_info); let pid_commands: HashMap = opencode_pids .iter() @@ -415,25 +377,16 @@ impl OpenCodeCollector { None } - /// Run a single sqlite3 query and parse the JSON output. - fn run_query(&self, sql: &str) -> Option> { - let db = self.db_path.to_str()?; - let output = Command::new("sqlite3") - .args(["-readonly", "-json", db]) - .arg(sql) - .output() - .ok()?; - if !output.status.success() { - return None; - } - let stdout = String::from_utf8_lossy(&output.stdout); - if stdout.trim().is_empty() { - return Some(vec![]); - } - serde_json::from_str(stdout.trim()).ok() + fn open_readonly(&self) -> Option { + rusqlite::Connection::open_with_flags( + &self.db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ).ok() } fn query_sessions(&self) -> Option> { + let conn = self.open_readonly()?; + let session_sql = format!( r#" SELECT @@ -483,65 +436,86 @@ LIMIT {};"#, MAX_SESSIONS ); - // Two separate invocations to avoid fragile concatenated JSON parsing - let rows = self.run_query(&session_sql)?; - let model_rows = self.run_query(&model_sql).unwrap_or_default(); - - // Build model lookup by session id let mut model_map: HashMap, u64)> = HashMap::new(); - for mr in &model_rows { - if let Some(id) = mr["id"].as_str() { + if let Ok(mut stmt) = conn.prepare(&model_sql) { + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, i64>(5)?, + )) + }).ok()?; + for (id, model, provider, last_role, last_completed, last_total_tokens) in rows.flatten() { model_map.insert( - id.to_string(), + id.clone(), ( - sanitize_db_field(mr["model"].as_str().unwrap_or(""), 256), - sanitize_db_field(mr["provider"].as_str().unwrap_or(""), 256), - sanitize_db_field(mr["last_role"].as_str().unwrap_or(""), 64), - mr["last_completed"].as_u64(), - mr["last_total_tokens"].as_u64().unwrap_or(0), + sanitize_db_field(&model, 256), + sanitize_db_field(&provider, 256), + sanitize_db_field(&last_role, 64), + last_completed.map(|v| v as u64), + last_total_tokens as u64, ), ); } } let mut sessions = Vec::new(); - for row in rows { - let id = row["id"].as_str().unwrap_or("").to_string(); - let (model, provider, last_role, last_completed, last_total_tokens) = model_map - .remove(&id) - .unwrap_or_else(|| ("".to_string(), "".to_string(), "".to_string(), None, 0)); - - // Sanitize DB-sourced strings before they reach the TUI/JSON snapshot. - let title = sanitize_db_title(row["title"].as_str().unwrap_or("")); - let directory = sanitize_db_field(row["directory"].as_str().unwrap_or(""), 4096); - let version = sanitize_db_field(row["version"].as_str().unwrap_or(""), 64); - let project_name = sanitize_db_field(row["project_name"].as_str().unwrap_or(""), 256); - - sessions.push(DbSession { - id, - title, - directory, - version, - // time_created is in milliseconds since epoch - time_created: row["time_created"].as_u64().unwrap_or(0), - project_name, - turn_count: row["turn_count"].as_u64().unwrap_or(0) as u32, - total_input: row["total_input"].as_u64().unwrap_or(0), - total_output: row["total_output"].as_u64().unwrap_or(0), - total_cache_read: row["total_cache_read"].as_u64().unwrap_or(0), - total_cache_write: row["total_cache_write"].as_u64().unwrap_or(0), - model, - provider, - last_role, - last_completed, - last_total_tokens, - }); + if let Ok(mut stmt) = conn.prepare(&session_sql) { + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, String>(6)?, + row.get::<_, i64>(7)?, + row.get::<_, i64>(8)?, + row.get::<_, i64>(9)?, + row.get::<_, i64>(10)?, + row.get::<_, i64>(11)?, + )) + }).ok()?; + for (id, raw_title, directory, version, time_created, project_name, + turn_count, total_input, total_output, total_cache_read, total_cache_write) in rows.flatten() { + let (model, provider, last_role, last_completed, last_total_tokens) = model_map + .remove(&id) + .unwrap_or_else(|| ("".to_string(), "".to_string(), "".to_string(), None, 0)); + + let title = sanitize_db_title(&raw_title); + let directory = sanitize_db_field(&directory, 4096); + let version = sanitize_db_field(&version, 64); + let project_name = sanitize_db_field(&project_name, 256); + + sessions.push(DbSession { + id, + title, + directory, + version, + time_created: time_created as u64, + project_name, + turn_count: turn_count as u32, + total_input: total_input as u64, + total_output: total_output as u64, + total_cache_read: total_cache_read as u64, + total_cache_write: total_cache_write as u64, + model, + provider, + last_role, + last_completed, + last_total_tokens, + }); + } } Some(sessions) } fn query_subagents(&self) -> Option> { + let conn = self.open_readonly()?; let sql = format!( r#" SELECT @@ -553,19 +527,25 @@ WHERE parent_id IS NOT NULL AND parent_id != '' LIMIT {};"#, MAX_SESSIONS ); - let rows = self.run_query(&sql)?; let mut subagents = Vec::new(); - for row in rows { - let parent_id = row["parent_id"].as_str().unwrap_or("").to_string(); - let title = sanitize_db_title(row["title"].as_str().unwrap_or("")); - let tokens = row["tokens"].as_u64().unwrap_or(0); - let time_updated = row["time_updated"].as_u64().unwrap_or(0); - subagents.push(DbSubAgent { - parent_id, - title, - tokens, - time_updated, - }); + if let Ok(mut stmt) = conn.prepare(&sql) { + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + )) + }).ok()?; + for (parent_id, title, tokens, time_updated) in rows.flatten() { + let title = sanitize_db_title(&title); + subagents.push(DbSubAgent { + parent_id, + title, + tokens: tokens as u64, + time_updated: time_updated as u64, + }); + } } Some(subagents) } @@ -576,6 +556,7 @@ LIMIT {};"#, if session_ids.is_empty() { return Some(HashMap::new()); } + let conn = self.open_readonly()?; let formatted_ids: Vec = session_ids.iter().map(|id| format!("'{}'", id)).collect(); let sql = format!( r#" @@ -590,64 +571,68 @@ ORDER BY p.time_created ASC;"#, formatted_ids.join(",") ); - let rows = self.run_query(&sql)?; let mut map: HashMap, Vec)> = HashMap::new(); for id in session_ids { map.insert(id.clone(), (Vec::new(), Vec::new())); } - for row in rows { - let session_id = row["session_id"].as_str().unwrap_or("").to_string(); - let role_str = row["role"].as_str().unwrap_or(""); - let part_data_str = row["part_data"].as_str().unwrap_or(""); - - if let Some((chat, tools)) = map.get_mut(&session_id) { - if let Ok(obj) = serde_json::from_str::(part_data_str) { - let part_type = obj["type"].as_str().unwrap_or(""); - if part_type == "text" || part_type == "reasoning" { - if let Some(text) = obj["text"].as_str() { - if !text.trim().is_empty() { - let role = if role_str == "user" { - ChatRole::User - } else { - ChatRole::Assistant - }; - let redacted_text = super::redact_secrets(&super::sanitize_terminal_text(text)); - chat.push(ChatMessage { - role, - text: redacted_text, - }); + if let Ok(mut stmt) = conn.prepare(&sql) { + let rows = stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }).ok()?; + for (session_id, role_str, part_data_str) in rows.flatten() { + if let Some((chat, tools)) = map.get_mut(&session_id) { + if let Ok(obj) = serde_json::from_str::(&part_data_str) { + let part_type = obj["type"].as_str().unwrap_or(""); + if part_type == "text" || part_type == "reasoning" { + if let Some(text) = obj["text"].as_str() { + if !text.trim().is_empty() { + let role = if role_str == "user" { + ChatRole::User + } else { + ChatRole::Assistant + }; + let redacted_text = super::redact_secrets(&super::sanitize_terminal_text(text)); + chat.push(ChatMessage { + role, + text: redacted_text, + }); + } } - } - } else if part_type == "tool" { - let name = obj["tool"].as_str().unwrap_or("").to_string(); - if !name.is_empty() { - let mut arg = String::new(); - if let Some(input) = obj["state"]["input"].as_object() { - if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) { - arg = cmd.to_string(); - } else if let Some(path) = input.get("filePath").and_then(|v| v.as_str()) { - arg = path.to_string(); - } else if let Some(path) = input.get("path").and_then(|v| v.as_str()) { - arg = path.to_string(); - } else if let Some(pattern) = input.get("pattern").and_then(|v| v.as_str()) { - arg = pattern.to_string(); - } else if let Some(desc) = input.get("description").and_then(|v| v.as_str()) { - arg = desc.to_string(); - } else if !input.is_empty() { - if let Some(first_val) = input.values().next().and_then(|v| v.as_str()) { - arg = first_val.to_string(); + } else if part_type == "tool" { + let name = obj["tool"].as_str().unwrap_or("").to_string(); + if !name.is_empty() { + let mut arg = String::new(); + if let Some(input) = obj["state"]["input"].as_object() { + if let Some(cmd) = input.get("command").and_then(|v| v.as_str()) { + arg = cmd.to_string(); + } else if let Some(path) = input.get("filePath").and_then(|v| v.as_str()) { + arg = path.to_string(); + } else if let Some(path) = input.get("path").and_then(|v| v.as_str()) { + arg = path.to_string(); + } else if let Some(pattern) = input.get("pattern").and_then(|v| v.as_str()) { + arg = pattern.to_string(); + } else if let Some(desc) = input.get("description").and_then(|v| v.as_str()) { + arg = desc.to_string(); + } else if !input.is_empty() { + if let Some(first_val) = input.values().next().and_then(|v| v.as_str()) { + arg = first_val.to_string(); + } } } + let start = obj["state"]["time"]["start"].as_u64().unwrap_or(0); + let end = obj["state"]["time"]["end"].as_u64().unwrap_or(0); + let duration_ms = end.saturating_sub(start); + tools.push(ToolCall { + name, + arg, + duration_ms, + }); } - let start = obj["state"]["time"]["start"].as_u64().unwrap_or(0); - let end = obj["state"]["time"]["end"].as_u64().unwrap_or(0); - let duration_ms = end.saturating_sub(start); - tools.push(ToolCall { - name, - arg, - duration_ms, - }); } } } @@ -839,13 +824,7 @@ fn sanitize_db_field(raw: &str, max_bytes: usize) -> String { /// Truncate a string at a char boundary to avoid panics on multi-byte UTF-8. fn truncate_field(s: &mut String, max_bytes: usize) { - if s.len() > max_bytes { - let mut end = max_bytes; - while end > 0 && !s.is_char_boundary(end) { - end -= 1; - } - s.truncate(end); - } + super::truncate_at_char_boundary(s, max_bytes); } /// Compare a process cwd with a DB session directory. diff --git a/src/collector/rate_limit.rs b/src/collector/rate_limit.rs index 19056d5..71ef3cd 100644 --- a/src/collector/rate_limit.rs +++ b/src/collector/rate_limit.rs @@ -8,6 +8,8 @@ const CLAUDE_RATE_FILE: &str = "llmtop-rate-limits.json"; /// Cached Codex rate limit: ~/.cache/llmtop/codex-rate-limits.json const CODEX_CACHE_FILE: &str = "codex-rate-limits.json"; +const OPENCODE_RATE_FILE: &str = "llmtop-rate-limits.json"; + #[derive(Debug, Deserialize)] struct RateLimitFile { #[serde(default)] @@ -58,6 +60,18 @@ pub fn read_rate_limits(extra_dirs: &[PathBuf]) -> Vec { results } +pub fn read_opencode_rate_limits() -> Vec { + let mut results = Vec::new(); + let Some(home) = dirs::home_dir() else { + return results; + }; + let path = home.join(".config").join("opencode").join(OPENCODE_RATE_FILE); + if let Some(info) = read_rate_file(&path, "opencode") { + results.push(info); + } + results +} + /// Read cached Codex rate limit (fallback when no live session provides one). /// Rate limits have their own `resets_at` expiry and the cache is refreshed /// whenever the next Codex session runs, so the reader keeps serving the last diff --git a/src/config.rs b/src/config.rs index 6c4738a..1f6717f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,22 +3,26 @@ use std::path::PathBuf; #[derive(Clone, Copy)] pub struct PanelVisibility { pub context: bool, + pub quota: bool, pub tokens: bool, pub projects: bool, pub ports: bool, pub sessions: bool, pub mcp: bool, + pub gpu: bool, } impl Default for PanelVisibility { fn default() -> Self { Self { context: true, + quota: true, tokens: true, projects: true, ports: true, sessions: true, mcp: true, + gpu: true, } } } @@ -97,11 +101,13 @@ fn parse_config_body(content: &str) -> AppConfig { "theme" => config.theme = val.to_string(), "language" => config.language = val.to_string(), "show_context" => config.panels.context = parse_bool(val).unwrap_or(true), + "show_quota" => config.panels.quota = parse_bool(val).unwrap_or(true), "show_tokens" => config.panels.tokens = parse_bool(val).unwrap_or(true), "show_projects" => config.panels.projects = parse_bool(val).unwrap_or(true), "show_ports" => config.panels.ports = parse_bool(val).unwrap_or(true), "show_sessions" => config.panels.sessions = parse_bool(val).unwrap_or(true), "show_mcp" => config.panels.mcp = parse_bool(val).unwrap_or(true), + "show_gpu" => config.panels.gpu = parse_bool(val).unwrap_or(true), _ => {} } } @@ -159,11 +165,13 @@ pub fn save_theme(name: &str) -> Result<(), String> { pub fn save_panel_visibility(panels: &PanelVisibility) -> Result<(), String> { write_with_updates(&[ ("show_context", panels.context.to_string()), + ("show_quota", panels.quota.to_string()), ("show_tokens", panels.tokens.to_string()), ("show_projects", panels.projects.to_string()), ("show_ports", panels.ports.to_string()), ("show_sessions", panels.sessions.to_string()), ("show_mcp", panels.mcp.to_string()), + ("show_gpu", panels.gpu.to_string()), ]) } @@ -181,7 +189,9 @@ fn write_with_updates(updates: &[(&str, String)]) -> Result<(), String> { Err(e) => return Err(e.to_string()), }; let new_content = rewrite_kv_lines(&content, updates); - std::fs::write(&path, new_content).map_err(|e| e.to_string()) + let tmp = path.with_extension("tmp"); + std::fs::write(&tmp, &new_content).map_err(|e| e.to_string())?; + std::fs::rename(&tmp, &path).map_err(|e| e.to_string()) } /// Rewrite (or append) the listed `key = value` lines in a config body. diff --git a/src/gpu.rs b/src/gpu.rs new file mode 100644 index 0000000..be7c3e6 --- /dev/null +++ b/src/gpu.rs @@ -0,0 +1,78 @@ +use serde::Serialize; + +#[derive(Debug, Clone, Serialize)] +pub struct GpuMetrics { + pub index: u32, + pub name: String, + pub temp_c: u64, + pub utilization_pct: u32, + pub vram_used_mb: u64, + pub vram_total_mb: u64, + pub power_w: u32, + pub power_max_w: u32, +} + +#[derive(Debug, Default)] +pub struct GpuSampler { + #[cfg(feature = "nvidia")] + nvml: Option, + #[cfg(not(feature = "nvidia"))] + _phantom: (), +} + +impl GpuSampler { + pub fn new() -> Self { + #[cfg(feature = "nvidia")] + { + let nvml = nvml_wrapper::Nvml::init().ok(); + Self { nvml } + } + #[cfg(not(feature = "nvidia"))] + { + Self { _phantom: () } + } + } + + pub fn sample(&self) -> Vec { + #[cfg(feature = "nvidia")] + { + let Some(nvml) = &self.nvml else { + return Vec::new(); + }; + let Ok(count) = nvml.device_count() else { + return Vec::new(); + }; + let mut metrics = Vec::new(); + for i in 0..count { + let Ok(device) = nvml.device_by_index(i) else { + continue; + }; + let name = device.name().unwrap_or_else(|_| "Unknown".to_string()); + let temp_c = device + .temperature(nvml_wrapper::enum_wrappers::TemperatureSensor::Gpu) + .unwrap_or(0); + let utilization_pct = device.utilization_rates().map(|u| u.gpu).unwrap_or(0); + let mem_info = device.memory_info().ok(); + let vram_used_mb = mem_info.map(|m| m.used / (1024 * 1024)).unwrap_or(0); + let vram_total_mb = mem_info.map(|m| m.total / (1024 * 1024)).unwrap_or(0); + let power_w = device.power_usage().map(|p| p / 1000).unwrap_or(0); + let power_max_w = device.enforced_power_limit().map(|p| p / 1000).unwrap_or(0); + metrics.push(GpuMetrics { + index: i, + name, + temp_c, + utilization_pct, + vram_used_mb, + vram_total_mb, + power_w, + power_max_w, + }); + } + metrics + } + #[cfg(not(feature = "nvidia"))] + { + Vec::new() + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 78a123f..5948642 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -55,6 +55,7 @@ pub mod app; pub mod collector; pub mod config; pub mod demo; +pub mod gpu; pub mod host_info; pub mod jump; pub mod locale; @@ -253,7 +254,7 @@ fn run_app( KeyCode::Char('T') => app.tree_view = !app.tree_view, KeyCode::Char('l') => app.toggle_timeline(), KeyCode::Char('f') => app.toggle_file_audit(), - KeyCode::Char(c @ '1'..='6') => app.toggle_panel(c as u8 - b'0'), + KeyCode::Char(c @ '1'..='8') => app.toggle_panel(c as u8 - b'0'), KeyCode::Char('M') => app.toggle_mcp_session_suppression(), KeyCode::Char('t') => app.cycle_theme(), _ => {} @@ -298,7 +299,7 @@ fn run_app( KeyCode::Char('t') => app.cycle_theme(), KeyCode::Char('T') => app.tree_view = !app.tree_view, KeyCode::Char('l') | KeyCode::Char('L') => app.toggle_timeline(), - KeyCode::Char(c @ '1'..='6') => app.toggle_panel(c as u8 - b'0'), + KeyCode::Char(c @ '1'..='8') => app.toggle_panel(c as u8 - b'0'), KeyCode::Char('M') => app.toggle_mcp_session_suppression(), KeyCode::Char('c') => app.toggle_config(), KeyCode::Char('v') => app.toggle_view_menu(), @@ -385,16 +386,7 @@ fn handle_mouse_event(app: &mut App, mouse: MouseEvent, area: Rect) { /// overrides from a string for safe terminal output. Defeats CVE-2021-42574 /// (Trojan Source) style attacks via RTLO/LRO/PDF/isolate characters. fn sanitize_output(s: &str) -> String { - s.chars() - .filter(|c| { - !c.is_control() - && !matches!(*c, - '\u{202A}'..='\u{202E}' - | '\u{2066}'..='\u{2069}' - | '\u{200E}' - | '\u{200F}') - }) - .collect() + crate::collector::sanitize_terminal_text(s) } fn print_snapshot(app: &App) { diff --git a/src/locale.rs b/src/locale.rs index 1404b6b..aed6f7c 100644 --- a/src/locale.rs +++ b/src/locale.rs @@ -127,7 +127,7 @@ static LOCALE_EN: LazyLock> = LazyLock::ne m.insert("help.toggle_file_audit", "toggle file audit"); m.insert( "help.toggle_panels", - "toggle panels (context/tokens/projects/ports/sessions/mcp)", + "toggle panels (context/quota/tokens/projects/ports/sessions/mcp/gpu)", ); m.insert( "help.mcp_suppress", @@ -159,11 +159,13 @@ static LOCALE_EN: LazyLock> = LazyLock::ne m.insert("view.timeline", "timeline"); m.insert("view.file_audit", "file audit"); m.insert("view.context_panel", "context panel"); + m.insert("view.quota_panel", "quota panel"); m.insert("view.tokens_panel", "tokens panel"); m.insert("view.projects_panel", "projects panel"); m.insert("view.ports_panel", "ports panel"); m.insert("view.sessions_panel", "sessions panel"); m.insert("view.mcp_servers_panel", "mcp servers panel"); + m.insert("view.gpu_panel", "GPU panel"); m.insert("view.mcp_session_hide", "mcp session hide"); m.insert("view.cycle_theme", "cycle theme"); m.insert("view.key_toggle", "key = toggle · Esc = close "); @@ -216,6 +218,16 @@ static LOCALE_EN: LazyLock> = LazyLock::ne m.insert("mcp.default", "default"); m.insert("mcp.suppress_off", "suppress: off (M)"); + m.insert("gpu.no_gpu", "no NVIDIA GPU detected"); + + m.insert("quota.5h", "5h"); + m.insert("quota.7d", "7d"); + m.insert("quota.no_data", "no data"); + m.insert("quota.opencode_setup", "opencode --setup"); + m.insert("quota.total", "total"); + m.insert("quota.in", "in"); + m.insert("quota.go_dzen", "Go Dzen"); + // Config panel m.insert("config.title", " Config "); m.insert("config.theme", "Theme"); @@ -224,11 +236,13 @@ static LOCALE_EN: LazyLock> = LazyLock::ne m.insert("config.change", "Enter/Space to change"); m.insert("config.close", "Esc to close"); m.insert("config.context_panel", "Context panel (1)"); - m.insert("config.tokens_panel", "Tokens panel (2)"); - m.insert("config.projects_panel", "Projects panel (3)"); - m.insert("config.ports_panel", "Ports panel (4)"); - m.insert("config.sessions_panel", "Sessions panel (5)"); - m.insert("config.mcp_panel", "MCP servers (6)"); + m.insert("config.quota_panel", "Quota panel (2)"); + m.insert("config.tokens_panel", "Tokens panel (3)"); + m.insert("config.projects_panel", "Projects panel (4)"); + m.insert("config.ports_panel", "Ports panel (5)"); + m.insert("config.sessions_panel", "Sessions panel (6)"); + m.insert("config.mcp_panel", "MCP servers (7)"); + m.insert("config.gpu_panel", "GPU panel (8)"); // Terminal size too small m.insert("term.too_small", "Terminal size too small:"); @@ -393,11 +407,13 @@ static LOCALE_ZH: LazyLock> = LazyLock::ne m.insert("view.timeline", "时间线"); m.insert("view.file_audit", "文件审计"); m.insert("view.context_panel", "上下文面板"); + m.insert("view.quota_panel", "配额面板"); m.insert("view.tokens_panel", "词元面板"); m.insert("view.projects_panel", "项目面板"); m.insert("view.ports_panel", "端口面板"); m.insert("view.sessions_panel", "会话面板"); m.insert("view.mcp_servers_panel", "MCP 服务器面板"); + m.insert("view.gpu_panel", "GPU 面板"); m.insert("view.mcp_session_hide", "隐藏 MCP 会话"); m.insert("view.cycle_theme", "切换主题"); m.insert("view.key_toggle", "按键切换 · Esc 关闭 "); @@ -450,6 +466,16 @@ static LOCALE_ZH: LazyLock> = LazyLock::ne m.insert("mcp.default", "默认"); m.insert("mcp.suppress_off", "隐藏: 关闭 (M)"); + m.insert("gpu.no_gpu", "未检测到 NVIDIA GPU"); + + m.insert("quota.5h", "5小时"); + m.insert("quota.7d", "7天"); + m.insert("quota.no_data", "无数据"); + m.insert("quota.opencode_setup", "opencode --setup"); + m.insert("quota.total", "总计"); + m.insert("quota.in", "还有"); + m.insert("quota.go_dzen", "Go Dzen"); + // Config panel m.insert("config.title", " 配置 "); m.insert("config.theme", "主题"); @@ -458,11 +484,12 @@ static LOCALE_ZH: LazyLock> = LazyLock::ne m.insert("config.change", "Enter/空格 更改"); m.insert("config.close", "Esc 关闭"); m.insert("config.context_panel", "上下文面板 (1)"); - m.insert("config.tokens_panel", "词元面板 (2)"); - m.insert("config.projects_panel", "项目面板 (3)"); - m.insert("config.ports_panel", "端口面板 (4)"); - m.insert("config.sessions_panel", "会话面板 (5)"); - m.insert("config.mcp_panel", "MCP 服务器 (6)"); + m.insert("config.quota_panel", "配额面板 (2)"); + m.insert("config.tokens_panel", "词元面板 (3)"); + m.insert("config.projects_panel", "项目面板 (4)"); + m.insert("config.ports_panel", "端口面板 (5)"); + m.insert("config.sessions_panel", "会话面板 (6)"); + m.insert("config.mcp_panel", "MCP 服务器 (7)"); // Terminal size too small m.insert("term.too_small", "终端尺寸过小:"); diff --git a/src/model/session.rs b/src/model/session.rs index 2bc5f24..d0045f6 100644 --- a/src/model/session.rs +++ b/src/model/session.rs @@ -242,14 +242,7 @@ impl SessionFile { /// Truncate a string at a char boundary to avoid panics on multi-byte UTF-8. fn truncate_string(s: &mut String, max_bytes: usize) { - if s.len() > max_bytes { - // Find the last char boundary at or before max_bytes - let mut end = max_bytes; - while end > 0 && !s.is_char_boundary(end) { - end -= 1; - } - s.truncate(end); - } + crate::collector::truncate_at_char_boundary(s, max_bytes); } #[cfg(test)] diff --git a/src/snapshot.rs b/src/snapshot.rs index d89c257..51ce458 100644 --- a/src/snapshot.rs +++ b/src/snapshot.rs @@ -12,6 +12,7 @@ use crate::app::App; use crate::collector::mcp::ACTIVE_MTIME_SECS; +use crate::gpu::GpuMetrics; use crate::host_info::{AgentAggregate, HostMetrics}; use crate::model::{ ChatRole, ChildProcess, OrphanPort, RateLimitInfo, SessionStatus, MAX_CHAT_MESSAGES, @@ -48,6 +49,7 @@ pub struct Snapshot { pub orphan_ports: Vec, /// Detected MCP servers (currently `codex mcp-server`). pub mcp_servers: Vec, + pub gpu: Vec, } /// One chat line from the transcript tail (detail view only). @@ -283,6 +285,7 @@ impl App { rate_limits: self.rate_limits.clone(), orphan_ports: self.orphan_ports.clone(), mcp_servers, + gpu: self.gpu_metrics.clone(), } } } diff --git a/src/ui/config.rs b/src/ui/config.rs index 17d9aaa..6d914ac 100644 --- a/src/ui/config.rs +++ b/src/ui/config.rs @@ -51,6 +51,10 @@ pub(crate) fn draw_config_overlay(f: &mut Frame, app: &App, theme: &Theme) { t("config.context_panel"), toggle_str(&on_str, &off_str, app.show_context), ), + ( + t("config.quota_panel"), + toggle_str(&on_str, &off_str, app.show_quota), + ), ( t("config.tokens_panel"), toggle_str(&on_str, &off_str, app.show_tokens), @@ -71,6 +75,10 @@ pub(crate) fn draw_config_overlay(f: &mut Frame, app: &App, theme: &Theme) { t("config.mcp_panel"), toggle_str(&on_str, &off_str, app.show_mcp), ), + ( + t("config.gpu_panel"), + toggle_str(&on_str, &off_str, app.show_gpu), + ), ]; let mut lines = Vec::new(); diff --git a/src/ui/gpu.rs b/src/ui/gpu.rs new file mode 100644 index 0000000..6bdb3eb --- /dev/null +++ b/src/ui/gpu.rs @@ -0,0 +1,101 @@ +use crate::app::App; +use crate::locale::t; +use crate::theme::Theme; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use super::{btop_block_active, grad_at, make_gradient}; + +pub(crate) fn draw_gpu_panel(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { + draw_gpu_panel_active(f, app, area, theme, false); +} + +pub(crate) fn draw_gpu_panel_active( + f: &mut Frame, + app: &App, + area: Rect, + theme: &Theme, + active: bool, +) { + let cpu_grad = make_gradient(theme.cpu_grad.start, theme.cpu_grad.mid, theme.cpu_grad.end); + + let block = btop_block_active("gpu", "▣", theme.cpu_box, theme, active); + f.render_widget(block, area); + + let inner = Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), + }; + + if app.gpu_metrics.is_empty() { + let label = t("gpu.no_gpu"); + f.render_widget( + Paragraph::new(vec![Line::from(Span::styled( + format!(" {label}"), + Style::default().fg(theme.inactive_fg), + ))]), + inner, + ); + return; + } + + let mut lines: Vec = Vec::new(); + for gpu in &app.gpu_metrics { + let vram_pct = if gpu.vram_total_mb > 0 { + (gpu.vram_used_mb as f64 / gpu.vram_total_mb as f64) * 100.0 + } else { + 0.0 + }; + let temp_color = if gpu.temp_c > 85 { + let (r, g, b) = theme.cpu_grad.end; + Color::Rgb(r, g, b) + } else if gpu.temp_c > 65 { + let (r, g, b) = theme.cpu_grad.mid; + Color::Rgb(r, g, b) + } else { + let (r, g, b) = theme.cpu_grad.start; + Color::Rgb(r, g, b) + }; + let util_color = grad_at(&cpu_grad, gpu.utilization_pct as f64); + + let name_short = if gpu.name.len() > 16 { + &gpu.name[..16] + } else { + &gpu.name + }; + + lines.push(Line::from(vec![ + Span::styled( + format!(" GPU{} ", gpu.index), + Style::default().fg(theme.title).add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("{name_short:<16} "), + Style::default().fg(theme.main_fg), + ), + Span::styled( + format!("{:>3}°C ", gpu.temp_c), + Style::default().fg(temp_color), + ), + Span::styled( + format!("{:>3}% ", gpu.utilization_pct), + Style::default().fg(util_color), + ), + Span::styled( + format!("{}M/{:>5}M ", gpu.vram_used_mb, gpu.vram_total_mb), + Style::default().fg(grad_at(&cpu_grad, vram_pct)), + ), + Span::styled( + format!("{:>3}W/{:>3}W", gpu.power_w, gpu.power_max_w), + Style::default().fg(theme.graph_text), + ), + ])); + } + + f.render_widget(Paragraph::new(lines), inner); +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 89732ff..1187d27 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,11 +1,13 @@ mod config; mod context; mod footer; +mod gpu; mod header; mod help; mod mcp; mod ports; mod projects; +mod quota; mod sessions; mod tokens; mod view_menu; @@ -91,6 +93,34 @@ pub(crate) fn meter_bar( } +pub(crate) fn remaining_bar( + remaining_pct: f64, + width: usize, + gradient: &[Color; 101], + meter_bg: Color, +) -> Vec> { + if width == 0 { + return Vec::new(); + } + let clamped = remaining_pct.clamp(0.0, 100.0); + let filled = ((clamped / 100.0) * width as f64).round() as usize; + let used_pct = 100.0 - clamped; + let mut spans = Vec::new(); + for i in 0..width { + if i < filled { + let cell_pct = used_pct; + spans.push(Span::styled( + "■", + Style::default().fg(grad_at(gradient, cell_pct)), + )); + } else { + spans.push(Span::styled("■", Style::default().fg(meter_bg))); + } + } + spans +} + + // ── braille sparkline ──────────────────────────────────────────────────────── /// Render a braille sparkline from data points (0.0–1.0), colored with gradient. @@ -363,10 +393,12 @@ pub fn draw(f: &mut Frame, app: &App) { for (section, area) in layout.mid { match section { + NarrowSection::Quota => quota::draw_quota_panel(f, app, area, theme), NarrowSection::Tokens => tokens::draw_tokens_panel(f, app, area, theme), NarrowSection::Projects => projects::draw_projects_panel(f, app, area, theme), NarrowSection::Ports => ports::draw_ports_panel(f, app, area, theme), NarrowSection::Mcp => mcp::draw_mcp_panel(f, app, area, theme), + NarrowSection::Gpu => gpu::draw_gpu_panel(f, app, area, theme), NarrowSection::Sessions | NarrowSection::Context => {} } } @@ -385,6 +417,9 @@ fn desktop_layout(app: &App, area: Rect) -> DesktopLayout { const MID_MIN: u16 = 6; let mut mid_sections = Vec::new(); + if app.show_quota { + mid_sections.push(NarrowSection::Quota); + } if app.show_tokens { mid_sections.push(NarrowSection::Tokens); } @@ -397,6 +432,9 @@ fn desktop_layout(app: &App, area: Rect) -> DesktopLayout { if app.show_mcp { mid_sections.push(NarrowSection::Mcp); } + if app.show_gpu { + mid_sections.push(NarrowSection::Gpu); + } let any_mid = !mid_sections.is_empty(); let mid_h_ideal: u16 = 8; @@ -632,9 +670,11 @@ fn draw_narrow_section( projects::draw_projects_panel_active(f, app, area, theme, active) } NarrowSection::Context => context::draw_context_panel_active(f, app, area, theme, active), + NarrowSection::Quota => quota::draw_quota_panel_active(f, app, area, theme, active), NarrowSection::Tokens => tokens::draw_tokens_panel_active(f, app, area, theme, active), NarrowSection::Ports => ports::draw_ports_panel_active(f, app, area, theme, active), NarrowSection::Mcp => mcp::draw_mcp_panel_active(f, app, area, theme, active), + NarrowSection::Gpu => gpu::draw_gpu_panel_active(f, app, area, theme, active), } } @@ -1161,12 +1201,12 @@ mod tests { x: 0, y: 0, width: 69, - height: 27, + height: 39, }; let body = narrow_chunks(area)[1]; let usage_sections = narrow_section_areas(&app, NarrowTab::Usage, body); - assert_eq!(usage_sections.len(), 2); + assert_eq!(usage_sections.len(), 3); let min_h = usage_sections .iter() .map(|(_, area)| area.height) @@ -1215,17 +1255,17 @@ mod tests { }; let body = narrow_chunks(area)[1]; - app.toggle_narrow_section_zoom(NarrowSection::Tokens); + app.toggle_narrow_section_zoom(NarrowSection::Quota); let sections = narrow_section_areas(&app, NarrowTab::Usage, body); assert_eq!(sections.len(), 1); - assert_eq!(sections[0], (NarrowSection::Tokens, body)); + assert_eq!(sections[0], (NarrowSection::Quota, body)); let backend = TestBackend::new(69, 27); let mut terminal = Terminal::new(backend).unwrap(); terminal.draw(|f| draw(f, &app)).unwrap(); let text = format!("{}", terminal.backend()); assert!( - text.contains("tokens") && text.contains("(*)"), + text.contains("quota") && text.contains("(*)"), "zoomed section should stay active\n{text}" ); assert!( @@ -1296,7 +1336,7 @@ mod tests { #[test] fn desktop_size_keeps_mid_panels() { let text = render_demo(120, 40); - for label in ["tokens", "projects", "ports", "sessions"] { + for label in ["tokens", "projects", "ports", "sessions", "quota"] { assert!( text.contains(label), "desktop should render {label}\n{text}" diff --git a/src/ui/quota.rs b/src/ui/quota.rs new file mode 100644 index 0000000..c76b165 --- /dev/null +++ b/src/ui/quota.rs @@ -0,0 +1,242 @@ +use crate::app::App; +use crate::locale::t; +use crate::model::RateLimitInfo; +use crate::theme::Theme; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use super::{btop_block_active, fmt_tokens, grad_at, make_gradient, remaining_bar, styled_label}; + +const STALE_SECS: u64 = 600; + +const SOURCES: &[&str] = &["opencode"]; + +pub(crate) fn draw_quota_panel(f: &mut Frame, app: &App, area: Rect, theme: &Theme) { + draw_quota_panel_active(f, app, area, theme, false); +} + +pub(crate) fn draw_quota_panel_active( + f: &mut Frame, + app: &App, + area: Rect, + theme: &Theme, + active: bool, +) { + let cpu_grad = make_gradient(theme.cpu_grad.start, theme.cpu_grad.mid, theme.cpu_grad.end); + + let block = btop_block_active("quota", "²", theme.cpu_box, theme, active); + f.render_widget(block, area); + + let inner = Rect { + x: area.x + 1, + y: area.y + 1, + width: area.width.saturating_sub(2), + height: area.height.saturating_sub(2), + }; + + let total_tokens: u64 = app.sessions.iter().map(|s| s.total_tokens()).sum(); + let rates = &app.token_rates; + let ticks_per_min: usize = 30; + let tokens_per_min: f64 = rates.iter().rev().take(ticks_per_min).sum(); + + let num_sources = SOURCES.len() as u16; + let col_w = inner.width / num_sources; + let content_h = inner.height.saturating_sub(1); + + for (i, source) in SOURCES.iter().enumerate() { + let col_x = inner.x + (i as u16) * col_w; + let this_w = if i as u16 == num_sources - 1 { + inner.width - (i as u16) * col_w + } else { + col_w + }; + let col_area = Rect { + x: col_x, + y: inner.y, + width: this_w, + height: content_h, + }; + + let rl = app + .rate_limits + .iter() + .find(|r| r.source.eq_ignore_ascii_case(source)); + draw_source_column(f, col_area, source, rl, &cpu_grad, theme); + } + + let bottom_area = Rect { + x: inner.x, + y: inner.y + content_h, + width: inner.width, + height: 1, + }; + let total_label = t("quota.total"); + let go_dzen_label = t("quota.go_dzen"); + f.render_widget( + Paragraph::new(vec![Line::from(vec![ + Span::styled( + format!(" {} {}", total_label, fmt_tokens(total_tokens)), + Style::default().fg(theme.main_fg), + ), + Span::styled( + format!(" {}/min", fmt_tokens(tokens_per_min as u64)), + Style::default().fg(theme.graph_text), + ), + Span::styled( + format!(" [{}]", go_dzen_label), + Style::default().fg(theme.session_id), + ), + ])]), + bottom_area, + ); +} + +fn draw_source_column( + f: &mut Frame, + area: Rect, + source: &str, + rl: Option<&RateLimitInfo>, + cpu_grad: &[ratatui::style::Color; 101], + theme: &Theme, +) { + let col_w_usize = area.width as usize; + let bar_w = col_w_usize.saturating_sub(10).clamp(2, 8); + + let Some(rl) = rl else { + let hint = t("quota.opencode_setup"); + let no_data = t("quota.no_data"); + let lines = vec![ + Line::from(Span::styled( + format!(" {}", source.to_uppercase()), + Style::default() + .fg(theme.title) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + format!(" — {}", no_data), + Style::default().fg(theme.inactive_fg), + )), + Line::from(Span::styled( + format!(" {}", hint), + Style::default().fg(theme.graph_text), + )), + ]; + f.render_widget(Paragraph::new(lines), area); + return; + }; + + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let is_stale = rl + .updated_at + .is_some_and(|ts| now.saturating_sub(ts) > STALE_SECS); + + let source_color = if is_stale { + theme.inactive_fg + } else { + theme.title + }; + + let mut lines: Vec = Vec::new(); + lines.push(Line::from(Span::styled( + format!(" {}", rl.source.to_uppercase()), + Style::default() + .fg(source_color) + .add_modifier(Modifier::BOLD), + ))); + + let show_reset = !is_stale; + + if let Some(used_pct) = rl.five_hour_pct { + let remaining = (100.0 - used_pct).clamp(0.0, 100.0); + let reset = if show_reset { + rl.five_hour_resets_at + .map(format_reset_time) + .unwrap_or_default() + } else { + String::new() + }; + let c = grad_at(cpu_grad, used_pct); + let label_5h = t("quota.5h"); + let mut s = vec![styled_label( + format!(" {}", label_5h).as_str(), + theme.graph_text, + )]; + s.extend(remaining_bar(remaining, bar_w, cpu_grad, theme.meter_bg)); + s.push(Span::styled( + format!(" {:>3.0}%", remaining), + Style::default().fg(c), + )); + lines.push(Line::from(s)); + lines.push(Line::from(Span::styled( + if reset.is_empty() { + String::new() + } else { + format!(" {}", reset) + }, + Style::default().fg(theme.graph_text), + ))); + } + if let Some(used_pct) = rl.seven_day_pct { + let remaining = (100.0 - used_pct).clamp(0.0, 100.0); + let reset = if show_reset { + rl.seven_day_resets_at + .map(format_reset_time) + .unwrap_or_default() + } else { + String::new() + }; + let c = grad_at(cpu_grad, used_pct); + let label_7d = t("quota.7d"); + let mut s = vec![styled_label( + format!(" {}", label_7d).as_str(), + theme.graph_text, + )]; + s.extend(remaining_bar(remaining, bar_w, cpu_grad, theme.meter_bg)); + s.push(Span::styled( + format!(" {:>3.0}%", remaining), + Style::default().fg(c), + )); + lines.push(Line::from(s)); + lines.push(Line::from(Span::styled( + if reset.is_empty() { + String::new() + } else { + format!(" {}", reset) + }, + Style::default().fg(theme.graph_text), + ))); + } + + f.render_widget(Paragraph::new(lines), area); +} + +pub(crate) fn format_reset_time(reset_ts: u64) -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if reset_ts <= now { + return String::new(); + } + let diff = reset_ts - now; + let prefix = t("quota.in"); + if diff < 60 { + format!("{} {}{}", prefix, diff, t("time.s")) + } else if diff < 3600 { + format!("{} {}{}", prefix, diff / 60, t("time.m")) + } else if diff < 86400 { + let h = diff / 3600; + let m = (diff % 3600) / 60; + format!("{} {}{} {}{}", prefix, h, t("time.h"), m, t("time.m")) + } else { + let d = diff / 86400; + let h = (diff % 86400) / 3600; + format!("{} {}{} {}{}", prefix, d, t("time.d"), h, t("time.h")) + } +} diff --git a/src/ui/sessions/detail.rs b/src/ui/sessions/detail.rs new file mode 100644 index 0000000..90d28ca --- /dev/null +++ b/src/ui/sessions/detail.rs @@ -0,0 +1,48 @@ +use crate::locale::t; +use crate::model::{AgentSession, ChatRole}; +use crate::theme::Theme; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use super::super::truncate_str; + +pub(crate) fn draw_chat_history(f: &mut Frame, session: &AgentSession, area: Rect, theme: &Theme) { + if session.chat_messages.is_empty() { + return; + } + + let mut lines = Vec::new(); + lines.push(Line::from(Span::styled( + format!( + " {} ({})", + t("detail.chat").as_str(), + session.chat_messages.len() + ), + Style::default() + .fg(theme.title) + .add_modifier(Modifier::BOLD), + ))); + + let visible_rows = area.height.saturating_sub(1) as usize; + let start = session.chat_messages.len().saturating_sub(visible_rows); + let text_w = (area.width as usize).saturating_sub(6); + + for msg in session.chat_messages.iter().skip(start) { + let (label, color) = match msg.role { + ChatRole::User => ("U", theme.hi_fg), + ChatRole::Assistant => ("A", theme.proc_misc), + }; + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", label), Style::default().fg(color)), + Span::styled( + truncate_str(&msg.text, text_w), + Style::default().fg(theme.main_fg), + ), + ])); + } + + f.render_widget(Paragraph::new(lines), area); +} diff --git a/src/ui/sessions/file_audit.rs b/src/ui/sessions/file_audit.rs new file mode 100644 index 0000000..3a2ce6e --- /dev/null +++ b/src/ui/sessions/file_audit.rs @@ -0,0 +1,58 @@ +use std::collections::HashSet; + +use crate::locale::t; +use crate::model::{AgentSession, FileOp}; +use crate::theme::Theme; +use ratatui::layout::Rect; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use super::super::truncate_str; + +pub(crate) fn draw_file_audit(f: &mut Frame, session: &AgentSession, area: Rect, theme: &Theme) { + let unique_files: HashSet<&str> = session + .file_accesses + .iter() + .map(|a| a.path.as_str()) + .collect(); + let unique_count = unique_files.len(); + let total_count = session.file_accesses.len(); + + let mut lines = Vec::new(); + lines.push(Line::from(Span::styled( + format!( + " {} ({} accesses, {} unique files)", + t("detail.file_audit").as_str(), + total_count, + unique_count + ), + Style::default() + .fg(theme.title) + .add_modifier(Modifier::BOLD), + ))); + + let max_rows = area.height.saturating_sub(1) as usize; + let max_path_w = (area.width as usize).saturating_sub(5); + + for access in session.file_accesses.iter().rev().take(max_rows) { + let (label, color) = match access.operation { + FileOp::Read => ("R", theme.session_id), + FileOp::Edit => ("E", theme.proc_misc), + FileOp::Write => ("W", theme.cpu_box), + }; + let max_path = max_path_w.saturating_sub(4); + let path_display = truncate_str(&access.path, max_path); + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", label), Style::default().fg(color)), + Span::styled(path_display, Style::default().fg(theme.main_fg)), + Span::styled( + format!(" t{}", access.turn_index), + Style::default().fg(theme.inactive_fg), + ), + ])); + } + + f.render_widget(Paragraph::new(lines), area); +} diff --git a/src/ui/sessions.rs b/src/ui/sessions/mod.rs similarity index 67% rename from src/ui/sessions.rs rename to src/ui/sessions/mod.rs index 08f5bb9..1c40c07 100644 --- a/src/ui/sessions.rs +++ b/src/ui/sessions/mod.rs @@ -1,6 +1,9 @@ +mod detail; +mod file_audit; +mod timeline; + use crate::app::App; use crate::locale::t; -use crate::model::{AgentSession, ChatRole, FileOp}; use crate::theme::Theme; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; @@ -21,7 +24,6 @@ pub(crate) fn draw_sessions_panel_active( theme: &Theme, active: bool, ) { - // Render the outer block let block = btop_block_active("sessions", "⁶", theme.proc_box, theme, active); f.render_widget(block, area); @@ -58,12 +60,11 @@ pub(crate) fn draw_sessions_panel_active( .direction(Direction::Vertical) .constraints([ Constraint::Length(table_h), - Constraint::Length(1), // separator line + Constraint::Length(1), Constraint::Min(0), ]) .split(inner); - // Draw separator line between session list and detail { let sep_area = panel_chunks[1]; let sep_line = "─".repeat(sep_area.width as usize); @@ -73,7 +74,6 @@ pub(crate) fn draw_sessions_panel_active( ); } - // ── Session list table ── let proc_grad = make_gradient( theme.proc_grad.start, theme.proc_grad.mid, @@ -81,8 +81,6 @@ pub(crate) fn draw_sessions_panel_active( ); let mut rows = Vec::new(); - // Responsive columns: keep the identity, current task, status, and context - // visible first; add lower-value columns back as width allows. let w = inner.width; let show_pid = w >= 120; let show_session_id = w >= 76; @@ -134,12 +132,12 @@ pub(crate) fn draw_sessions_panel_active( let marker = if selected { "►" } else { " " }; let (agent_label, agent_color) = match session.agent_cli { - "ollama" => ("OLM", Color::Rgb(235, 178, 50)), // warm gold - "llama.cpp" => ("LLC", Color::Rgb(168, 122, 255)), // vibrant purple - "vllm" => ("VLM", Color::Rgb(50, 178, 235)), // light blue - "opencode" => ("#OC", Color::Rgb(74, 222, 128)), // emerald green - "odysseus" => ("ODY", Color::Rgb(255, 140, 66)), // warm orange - "auto" => ("API", Color::Rgb(200, 200, 200)), // silver/grey + "ollama" => ("OLM", Color::Rgb(235, 178, 50)), + "llama.cpp" => ("LLC", Color::Rgb(168, 122, 255)), + "vllm" => ("VLM", Color::Rgb(50, 178, 235)), + "opencode" => ("#OC", Color::Rgb(74, 222, 128)), + "odysseus" => ("ODY", Color::Rgb(255, 140, 66)), + "auto" => ("API", Color::Rgb(200, 200, 200)), other => { let fallback: String = other.chars().take(3).collect::().to_uppercase(); ( @@ -257,7 +255,6 @@ pub(crate) fn draw_sessions_panel_active( rows.push(Row::new(cells).style(row_style).height(1)); - // 2nd line: task text in Summary column let summary_idx = 3 + show_pid as usize + show_session_id as usize + show_config as usize; let total_cols = 6 @@ -287,11 +284,9 @@ pub(crate) fn draw_sessions_panel_active( .collect(); rows.push(Row::new(task_cells).height(1)); - // Tree view: show subagents as indented rows if app.tree_view && !session.subagents.is_empty() { for (sa_idx, sa) in session.subagents.iter().enumerate() { let is_last = sa_idx == session.subagents.len() - 1; - // Tree connector fits the 3-wide agent column (was truncated before). let prefix = if is_last { "└─" } else { "├─" }; let is_working = sa.status.eq_ignore_ascii_case("working") || sa.status.eq_ignore_ascii_case("in_progress"); @@ -381,41 +376,39 @@ pub(crate) fn draw_sessions_panel_active( let header = Row::new(header_cells).height(1); let mut widths_vec: Vec = vec![ - Constraint::Length(1), // marker - Constraint::Length(3), // agent label + Constraint::Length(1), + Constraint::Length(3), ]; if show_pid { - widths_vec.push(Constraint::Length(6)); // pid + widths_vec.push(Constraint::Length(6)); } - widths_vec.push(Constraint::Length(project_w)); // project + widths_vec.push(Constraint::Length(project_w)); if show_session_id { - widths_vec.push(Constraint::Length(session_w)); // session id + widths_vec.push(Constraint::Length(session_w)); } if show_config { - widths_vec.push(Constraint::Length(config_w)); // config root + widths_vec.push(Constraint::Length(config_w)); } - widths_vec.push(Constraint::Fill(1)); // summary (fills remaining) - widths_vec.push(Constraint::Length(status_w)); // status + widths_vec.push(Constraint::Fill(1)); + widths_vec.push(Constraint::Length(status_w)); if show_model { - widths_vec.push(Constraint::Length(model_w)); // model + widths_vec.push(Constraint::Length(model_w)); } - widths_vec.push(Constraint::Length(context_w)); // context + widths_vec.push(Constraint::Length(context_w)); if show_tokens { - widths_vec.push(Constraint::Length(tokens_w)); // tokens + widths_vec.push(Constraint::Length(tokens_w)); } if show_memory { - widths_vec.push(Constraint::Length(8)); // memory + widths_vec.push(Constraint::Length(8)); } if show_turn { - widths_vec.push(Constraint::Length(4)); // turn + widths_vec.push(Constraint::Length(4)); } - // Scroll: rows vary per session in tree view; use the built row list as the source of truth. let visible_sessions = app.visible_indices(); let total_rows = rows.len(); let needs_scroll = total_rows > panel_chunks[0].height.saturating_sub(1) as usize; - // Split table area into [table | scrollbar(1)] when scrollable let table_area; let scrollbar_area: Option; if needs_scroll && panel_chunks[0].width > 2 { @@ -430,9 +423,7 @@ pub(crate) fn draw_sessions_panel_active( scrollbar_area = None; } - let visible_rows = table_area.height.saturating_sub(1) as usize; // -1 for header - // Row offset for the selected session, accounting for subagent rows in tree view - // and filter-hidden sessions above it. + let visible_rows = table_area.height.saturating_sub(1) as usize; let selected_pos = visible_sessions .iter() .position(|&i| i == app.selected) @@ -468,7 +459,6 @@ pub(crate) fn draw_sessions_panel_active( let table = Table::new(visible, widths_vec).header(header); f.render_widget(table, table_area); - // ── Scrollbar column (dedicated 1-char width, btop-style) ── if let Some(sb) = scrollbar_area { let bar_h = sb.height as usize; if bar_h > 0 { @@ -495,7 +485,6 @@ pub(crate) fn draw_sessions_panel_active( buf[(sb.x, y)].set_symbol(ch).set_fg(color); } - // ↑/↓ arrows at edges when more content exists if scroll_offset > 0 { buf[(sb.x, sb.y)].set_symbol("↑").set_fg(theme.proc_box); } @@ -507,14 +496,12 @@ pub(crate) fn draw_sessions_panel_active( } } - // ── Detail section for selected session (full-width Paragraph, not Table) ── if let Some(session) = app.sessions.get(app.selected) { let detail_area = panel_chunks[2]; if detail_area.height < 3 { return; } - // Reserve bottom lines for MEM + version let footer_h = 3u16; let detail_body_h = detail_area.height.saturating_sub(footer_h); let detail_body = Rect { @@ -536,14 +523,8 @@ pub(crate) fn draw_sessions_panel_active( let has_chat = !session.chat_messages.is_empty(); let has_left_detail = has_children || has_subagents; let has_file_audit = app.show_file_audit && !session.file_accesses.is_empty(); - // Focus mode: file audit (F) takes priority over timeline (L) when both - // are toggled on. Only one "full lower" mode is active at a time. let file_audit_focused = has_file_audit; let timeline_focused = !file_audit_focused && app.show_timeline && has_tool_calls; - // Default detail is chat when available; otherwise fall back to the - // compact timeline split. Both modes split when there is useful - // left-side detail (children/subagents) on a wide enough terminal, - // and use the whole lower area otherwise. const CHAT_SPLIT_MIN_WIDTH: u16 = 120; let chat_default = !file_audit_focused && !app.show_timeline && has_chat; let chat_side_by_side = @@ -558,9 +539,8 @@ pub(crate) fn draw_sessions_panel_active( let timeline_side_by_side = timeline_default && has_left_detail; let timeline_full_width = timeline_default && !has_left_detail; - // Always show SESSION header (task) at top, then children/subagents/timeline/file_audit below let session_header_h: u16 = { - let mut h = 1u16; // SESSION title + let mut h = 1u16; if !session.initial_prompt.is_empty() { h += 1; } @@ -584,7 +564,6 @@ pub(crate) fn draw_sessions_panel_active( (detail_body, None) }; - // SESSION header — always rendered { let mut lines = Vec::new(); let sid_short = if session.session_id.len() >= 8 { @@ -622,20 +601,13 @@ pub(crate) fn draw_sessions_panel_active( f.render_widget(Paragraph::new(lines), header_area); } - // Layout below the session header: - // - file audit focus (F): full-width file audit - // - timeline focus (L): full-width timeline - // - default chat: full-width, or split with children/subagents on wide terminals - // - wide terminal with left detail + tool calls: split lower area - // - wide terminal with only tool calls: full-width timeline - // - otherwise: children/subagents only (or nothing) if let Some(lower) = lower_area { if file_audit_focused { - draw_file_audit(f, session, lower, theme); + file_audit::draw_file_audit(f, session, lower, theme); } else if timeline_focused || timeline_full_width { - draw_timeline(f, session, lower, theme, app.timeline_scroll); + timeline::draw_timeline(f, session, lower, theme, app.timeline_scroll); } else if chat_full_width { - draw_chat_history(f, session, lower, theme); + detail::draw_chat_history(f, session, lower, theme); } else { let (left_area, right_detail_area) = if chat_side_by_side || timeline_side_by_side { let split = Layout::default() @@ -649,9 +621,9 @@ pub(crate) fn draw_sessions_panel_active( if let Some(detail_area) = right_detail_area { if chat_side_by_side { - draw_chat_history(f, session, detail_area, theme); + detail::draw_chat_history(f, session, detail_area, theme); } else { - draw_timeline(f, session, detail_area, theme, app.timeline_scroll); + timeline::draw_timeline(f, session, detail_area, theme, app.timeline_scroll); } } @@ -681,7 +653,6 @@ pub(crate) fn draw_sessions_panel_active( .split(left_area) }; - // Children (left side) if has_children { let children_area = body_chunks[0]; let mut lines = Vec::new(); @@ -720,7 +691,6 @@ pub(crate) fn draw_sessions_panel_active( f.render_widget(Paragraph::new(lines), children_area); } - // Subagents (right side, or full width if no children) if has_subagents { let sa_area = if has_children { body_chunks[1] @@ -748,7 +718,6 @@ pub(crate) fn draw_sessions_panel_active( for (row_idx, sa) in left_agents.iter().enumerate() { let mut spans = Vec::new(); - // Left column let icon = if sa.status == "working" { "●" } else { "✓" }; let fg = if sa.status == "working" { theme.main_fg @@ -769,7 +738,6 @@ pub(crate) fn draw_sessions_panel_active( Style::default().fg(theme.graph_text), )); - // Right column if let Some(sa_r) = right_agents.get(row_idx) { let icon_r = if sa_r.status == "working" { "●" @@ -825,11 +793,10 @@ pub(crate) fn draw_sessions_panel_active( } f.render_widget(Paragraph::new(lines), sa_area); } - } // end if has_children || has_subagents - } // end else (not focused) + } + } } - // Footer: MEM + version (full width) { let cpu_grad = make_gradient(theme.cpu_grad.start, theme.cpu_grad.mid, theme.cpu_grad.end); @@ -839,7 +806,6 @@ pub(crate) fn draw_sessions_panel_active( theme.graph_text }; let mut footer_lines = vec![Line::from("")]; - // MEM line only for Claude Code sessions (Codex has no memory system) if session.agent_cli == "claude" { footer_lines.push(Line::from(Span::styled( format!( @@ -851,7 +817,6 @@ pub(crate) fn draw_sessions_panel_active( Style::default().fg(mem_color), ))); } - // Context evolution sparkline (if history available) if !session.context_history.is_empty() && session.context_window > 0 { let normalized: Vec = session .context_history @@ -899,99 +864,9 @@ pub(crate) fn draw_sessions_panel_active( } } -/// Render the recent user/assistant chat tail for the selected session. -fn draw_chat_history(f: &mut Frame, session: &AgentSession, area: Rect, theme: &Theme) { - if session.chat_messages.is_empty() { - return; - } - - let mut lines = Vec::new(); - lines.push(Line::from(Span::styled( - format!( - " {} ({})", - t("detail.chat").as_str(), - session.chat_messages.len() - ), - Style::default() - .fg(theme.title) - .add_modifier(Modifier::BOLD), - ))); - - let visible_rows = area.height.saturating_sub(1) as usize; - let start = session.chat_messages.len().saturating_sub(visible_rows); - let text_w = (area.width as usize).saturating_sub(6); - - for msg in session.chat_messages.iter().skip(start) { - let (label, color) = match msg.role { - ChatRole::User => ("U", theme.hi_fg), - ChatRole::Assistant => ("A", theme.proc_misc), - }; - lines.push(Line::from(vec![ - Span::styled(format!(" {} ", label), Style::default().fg(color)), - Span::styled( - truncate_str(&msg.text, text_w), - Style::default().fg(theme.main_fg), - ), - ])); - } - - f.render_widget(Paragraph::new(lines), area); -} - -/// Render the file access audit log in the given area. -fn draw_file_audit(f: &mut Frame, session: &AgentSession, area: Rect, theme: &Theme) { - use std::collections::HashSet; - let unique_files: HashSet<&str> = session - .file_accesses - .iter() - .map(|a| a.path.as_str()) - .collect(); - let unique_count = unique_files.len(); - let total_count = session.file_accesses.len(); - - let mut lines = Vec::new(); - lines.push(Line::from(Span::styled( - format!( - " {} ({} accesses, {} unique files)", - t("detail.file_audit").as_str(), - total_count, - unique_count - ), - Style::default() - .fg(theme.title) - .add_modifier(Modifier::BOLD), - ))); - - let max_rows = area.height.saturating_sub(1) as usize; - let max_path_w = (area.width as usize).saturating_sub(5); - - // Show most recent entries first (reverse order), limited to available rows - for access in session.file_accesses.iter().rev().take(max_rows) { - let (label, color) = match access.operation { - FileOp::Read => ("R", theme.session_id), // blue - FileOp::Edit => ("E", theme.proc_misc), // yellow - FileOp::Write => ("W", theme.cpu_box), // cyan - }; - let max_path = max_path_w.saturating_sub(4); // room for turn index - let path_display = truncate_str(&access.path, max_path); - lines.push(Line::from(vec![ - Span::styled(format!(" {} ", label), Style::default().fg(color)), - Span::styled(path_display, Style::default().fg(theme.main_fg)), - Span::styled( - format!(" t{}", access.turn_index), - Style::default().fg(theme.inactive_fg), - ), - ])); - } - - f.render_widget(Paragraph::new(lines), area); -} - pub(crate) fn shorten_model(model: &str, is_1m: bool) -> String { - // "claude-opus-4-6" → "opus4.6", "claude-sonnet-4-6" → "sonnet4.6", "claude-haiku-4-5" → "haiku4.5" let s = model.strip_prefix("claude-").unwrap_or(model); let s = s.trim_end_matches("[1m]"); - // Extract name and version: "opus-4-6" → ("opus", "4.6") let base = if let Some(pos) = s.find(|c: char| c.is_ascii_digit()) { let name = s[..pos].trim_end_matches('-'); let ver = s[pos..].replace('-', "."); @@ -1006,251 +881,11 @@ pub(crate) fn shorten_model(model: &str, is_1m: bool) -> String { } } -/// Tool name → color mapping for timeline bars, using theme palette. -fn tool_color(name: &str, theme: &Theme) -> Color { - match name { - "Read" => theme.session_id, // typically a warm accent - "Edit" => theme.proc_misc, // green/active color - "Write" => theme.cpu_box, // box/border accent - "Bash" => theme.hi_fg, // highlight foreground - "shell" | "exec_command" | "write_stdin" => theme.hi_fg, - "apply_patch" => theme.proc_misc, - "update_plan" => theme.title, - "spawn_agent" | "send_input" | "wait_agent" => theme.title, - "view_image" => theme.session_id, - "Grep" => theme.status_fg, // status accent - "Glob" => theme.graph_text, // subtle text - "find" | "list_mcp_resources" | "read_mcp_resource" => theme.status_fg, - "Agent" => theme.title, // title/emphasis - "Skill" => theme.selected_fg, // selected foreground - _ => theme.inactive_fg, // fallback - } -} - -fn tool_label(name: &str) -> &str { - match name { - "exec_command" | "shell" => "Exec", - "write_stdin" => "Input", - "apply_patch" => "Patch", - "update_plan" => "Plan", - "spawn_agent" => "Agent", - "send_input" => "Send", - "wait_agent" => "Wait", - "view_image" => "Image", - "list_mcp_resources" | "read_mcp_resource" => "MCP", - other => other, - } -} - -fn fmt_duration(ms: u64) -> String { - if ms >= 60_000 { - format!("{}m{:.0}s", ms / 60_000, (ms % 60_000) as f64 / 1000.0) - } else if ms >= 1000 { - format!("{:.1}s", ms as f64 / 1000.0) - } else { - format!("{}ms", ms) - } -} - -fn draw_timeline( - f: &mut Frame, - session: &crate::model::AgentSession, - area: Rect, - theme: &Theme, - scroll: usize, -) { - let tool_calls = &session.tool_calls; - let is_thinking = session.thinking_since_ms > 0 - && matches!( - session.status, - crate::model::SessionStatus::Thinking - | crate::model::SessionStatus::Executing - | crate::model::SessionStatus::Waiting - | crate::model::SessionStatus::Unknown - ); - if tool_calls.is_empty() && !is_thinking { - return; - } - - // Live duration for any tool still in flight. The collector leaves - // `duration_ms == 0` on tools whose assistant turn hasn't been closed yet; - // combined with `pending_since_ms > 0` that means "tool started at - // pending_since_ms and is still running right now." We compute the elapsed - // ms on every frame so the bar appears to grow in real time. - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or(0); - let live_duration = |tc: &crate::model::ToolCall| -> u64 { - if tc.duration_ms > 0 { - tc.duration_ms - } else if session.pending_since_ms > 0 { - now_ms.saturating_sub(session.pending_since_ms) - } else { - 0 - } - }; - let is_pending = |tc: &crate::model::ToolCall| -> bool { - tc.duration_ms == 0 && session.pending_since_ms > 0 - }; - let thinking_duration = if is_thinking { - now_ms.saturating_sub(session.thinking_since_ms) - } else { - 0 - }; - - let total_duration: u64 = tool_calls.iter().map(live_duration).sum(); - let max_duration = tool_calls - .iter() - .map(live_duration) - .max() - .unwrap_or(1) - .max(1); - - let mut lines = Vec::new(); - - // Header — note "1 running" / "thinking Xs" if the session is live. - let pending_count = tool_calls.iter().filter(|tc| is_pending(tc)).count(); - let mut status_notes: Vec = Vec::new(); - if pending_count > 0 { - status_notes.push(format!("{} running", pending_count)); - } - if is_thinking { - status_notes.push(format!("thinking {}", fmt_duration(thinking_duration))); - } - let running_note = if status_notes.is_empty() { - String::new() - } else { - format!(", {}", status_notes.join(", ")) - }; - lines.push(Line::from(vec![Span::styled( - format!( - " {} ({} calls, {}{})", - t("detail.timeline").as_str(), - tool_calls.len(), - fmt_duration(total_duration), - running_note, - ), - Style::default() - .fg(theme.title) - .add_modifier(Modifier::BOLD), - )])); - - // Available width for the bar: total width - name(7) - arg(22) - duration(8) - padding(5) - let bar_width = (area.width as usize).saturating_sub(42).max(5); - - // Render each tool call as a row. Reserve the last visible row for the - // live Thinking row when the model is between turns. - let header_rows = 1; - let thinking_rows = if is_thinking { 1 } else { 0 }; - let visible_rows = (area.height as usize).saturating_sub(header_rows + thinking_rows); - let start = scroll.min(tool_calls.len().saturating_sub(visible_rows)); - - for tc in tool_calls.iter().skip(start).take(visible_rows) { - let duration = live_duration(tc); - let pending = is_pending(tc); - let bar_fill = if max_duration > 0 { - ((duration as f64 / max_duration as f64) * bar_width as f64).ceil() as usize - } else { - 0 - }; - let bar_fill = bar_fill.min(bar_width); - let bar_empty = bar_width - bar_fill; - - let is_longest = duration == max_duration && max_duration > 0 && !pending; - let star = if is_longest { " *" } else { "" }; - - let color = tool_color(&tc.name, theme); - // Prefix running rows with a pulsing ● so they're obvious at a glance. - // The pulse is cheap: flip between bright/dim on a 2-tick (~4s) cycle - // using the same clock we used for the live duration. - let pulse_bright = pending && (now_ms / 500).is_multiple_of(2); - let name_prefix = if pending { "●" } else { " " }; - let name_style = if pending { - Style::default().fg(color).add_modifier(if pulse_bright { - Modifier::BOLD - } else { - Modifier::DIM - }) - } else { - Style::default().fg(color).add_modifier(Modifier::BOLD) - }; - let bar_style = if pending { - Style::default().fg(color).add_modifier(Modifier::DIM) - } else { - Style::default().fg(color) - }; - - let duration_label = if pending { - format!(" {:>5}…", fmt_duration(duration)) - } else { - format!(" {:>6}{}", fmt_duration(duration), star) - }; - let duration_color = if is_longest { - theme.proc_misc - } else if pending { - color - } else { - theme.graph_text - }; - - let name_label = super::truncate_str(tool_label(&tc.name), 6); - lines.push(Line::from(vec![ - Span::styled(format!("{}{:<6}", name_prefix, name_label), name_style), - Span::styled( - format!(" {:<20}", super::truncate_str(&tc.arg, 20)), - Style::default().fg(theme.graph_text), - ), - Span::styled(" ", Style::default()), - Span::styled("█".repeat(bar_fill), bar_style), - Span::styled("░".repeat(bar_empty), Style::default().fg(theme.div_line)), - Span::styled(duration_label, Style::default().fg(duration_color)), - ])); - } - - // Virtual "Thinking" row — the model is generating its next turn, which - // never shows up as a tool_use in the JSONL. Growth scales against the - // longest tool so short thinks fill gradually; long thinks cap at full. - if is_thinking { - let color = theme.title; - let pulse_bright = (now_ms / 500).is_multiple_of(2); - let bar_fill = if max_duration > 0 { - ((thinking_duration as f64 / max_duration as f64) * bar_width as f64).ceil() as usize - } else { - bar_width - }; - let bar_fill = bar_fill.min(bar_width); - let bar_empty = bar_width - bar_fill; - let name_style = Style::default().fg(color).add_modifier(if pulse_bright { - Modifier::BOLD - } else { - Modifier::DIM - }); - let bar_style = Style::default().fg(color).add_modifier(Modifier::DIM); - lines.push(Line::from(vec![ - Span::styled("●Think ", name_style), - Span::styled( - format!(" {:<20}", "generating reply"), - Style::default().fg(theme.graph_text), - ), - Span::styled(" ", Style::default()), - Span::styled("█".repeat(bar_fill), bar_style), - Span::styled("░".repeat(bar_empty), Style::default().fg(theme.div_line)), - Span::styled( - format!(" {:>5}…", fmt_duration(thinking_duration)), - Style::default().fg(color), - ), - ])); - } - - f.render_widget(Paragraph::new(lines), area); -} - #[cfg(test)] mod tests { use super::*; use crate::config::PanelVisibility; - use crate::model::SessionStatus; + use crate::model::{AgentSession, SessionStatus}; use ratatui::backend::TestBackend; use ratatui::Terminal; @@ -1258,16 +893,16 @@ mod tests { fn codex_exec_command_uses_bash_color() { let theme = Theme::default(); assert_eq!( - tool_color("exec_command", &theme), - tool_color("Bash", &theme) + timeline::tool_color("exec_command", &theme), + timeline::tool_color("Bash", &theme) ); } #[test] fn codex_tool_labels_fit_timeline_name_column() { - assert_eq!(tool_label("exec_command"), "Exec"); - assert_eq!(tool_label("update_plan"), "Plan"); - assert!(tool_label("exec_command").len() <= 6); + assert_eq!(timeline::tool_label("exec_command"), "Exec"); + assert_eq!(timeline::tool_label("update_plan"), "Plan"); + assert!(timeline::tool_label("exec_command").len() <= 6); } #[test] diff --git a/src/ui/sessions/timeline.rs b/src/ui/sessions/timeline.rs new file mode 100644 index 0000000..d9ae61e --- /dev/null +++ b/src/ui/sessions/timeline.rs @@ -0,0 +1,234 @@ +use crate::locale::t; +use crate::model::AgentSession; +use crate::theme::Theme; +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use super::super::{truncate_str}; + +pub(crate) fn draw_timeline( + f: &mut Frame, + session: &AgentSession, + area: Rect, + theme: &Theme, + scroll: usize, +) { + let tool_calls = &session.tool_calls; + let is_thinking = session.thinking_since_ms > 0 + && matches!( + session.status, + crate::model::SessionStatus::Thinking + | crate::model::SessionStatus::Executing + | crate::model::SessionStatus::Waiting + | crate::model::SessionStatus::Unknown + ); + if tool_calls.is_empty() && !is_thinking { + return; + } + + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + let live_duration = |tc: &crate::model::ToolCall| -> u64 { + if tc.duration_ms > 0 { + tc.duration_ms + } else if session.pending_since_ms > 0 { + now_ms.saturating_sub(session.pending_since_ms) + } else { + 0 + } + }; + let is_pending = |tc: &crate::model::ToolCall| -> bool { + tc.duration_ms == 0 && session.pending_since_ms > 0 + }; + let thinking_duration = if is_thinking { + now_ms.saturating_sub(session.thinking_since_ms) + } else { + 0 + }; + + let total_duration: u64 = tool_calls.iter().map(live_duration).sum(); + let max_duration = tool_calls + .iter() + .map(live_duration) + .max() + .unwrap_or(1) + .max(1); + + let mut lines = Vec::new(); + + let pending_count = tool_calls.iter().filter(|tc| is_pending(tc)).count(); + let mut status_notes: Vec = Vec::new(); + if pending_count > 0 { + status_notes.push(format!("{} running", pending_count)); + } + if is_thinking { + status_notes.push(format!("thinking {}", fmt_duration(thinking_duration))); + } + let running_note = if status_notes.is_empty() { + String::new() + } else { + format!(", {}", status_notes.join(", ")) + }; + lines.push(Line::from(vec![Span::styled( + format!( + " {} ({} calls, {}{})", + t("detail.timeline").as_str(), + tool_calls.len(), + fmt_duration(total_duration), + running_note, + ), + Style::default() + .fg(theme.title) + .add_modifier(Modifier::BOLD), + )])); + + let bar_width = (area.width as usize).saturating_sub(42).max(5); + + let header_rows = 1; + let thinking_rows = if is_thinking { 1 } else { 0 }; + let visible_rows = (area.height as usize).saturating_sub(header_rows + thinking_rows); + let start = scroll.min(tool_calls.len().saturating_sub(visible_rows)); + + for tc in tool_calls.iter().skip(start).take(visible_rows) { + let duration = live_duration(tc); + let pending = is_pending(tc); + let bar_fill = if max_duration > 0 { + ((duration as f64 / max_duration as f64) * bar_width as f64).ceil() as usize + } else { + 0 + }; + let bar_fill = bar_fill.min(bar_width); + let bar_empty = bar_width - bar_fill; + + let is_longest = duration == max_duration && max_duration > 0 && !pending; + let star = if is_longest { " *" } else { "" }; + + let color = tool_color(&tc.name, theme); + let pulse_bright = pending && (now_ms / 500).is_multiple_of(2); + let name_prefix = if pending { "●" } else { " " }; + let name_style = if pending { + Style::default().fg(color).add_modifier(if pulse_bright { + Modifier::BOLD + } else { + Modifier::DIM + }) + } else { + Style::default().fg(color).add_modifier(Modifier::BOLD) + }; + let bar_style = if pending { + Style::default().fg(color).add_modifier(Modifier::DIM) + } else { + Style::default().fg(color) + }; + + let duration_label = if pending { + format!(" {:>5}…", fmt_duration(duration)) + } else { + format!(" {:>6}{}", fmt_duration(duration), star) + }; + let duration_color = if is_longest { + theme.proc_misc + } else if pending { + color + } else { + theme.graph_text + }; + + let name_label = truncate_str(tool_label(&tc.name), 6); + lines.push(Line::from(vec![ + Span::styled(format!("{}{:<6}", name_prefix, name_label), name_style), + Span::styled( + format!(" {:<20}", truncate_str(&tc.arg, 20)), + Style::default().fg(theme.graph_text), + ), + Span::styled(" ", Style::default()), + Span::styled("█".repeat(bar_fill), bar_style), + Span::styled("░".repeat(bar_empty), Style::default().fg(theme.div_line)), + Span::styled(duration_label, Style::default().fg(duration_color)), + ])); + } + + if is_thinking { + let color = theme.title; + let pulse_bright = (now_ms / 500).is_multiple_of(2); + let bar_fill = if max_duration > 0 { + ((thinking_duration as f64 / max_duration as f64) * bar_width as f64).ceil() as usize + } else { + bar_width + }; + let bar_fill = bar_fill.min(bar_width); + let bar_empty = bar_width - bar_fill; + let name_style = Style::default().fg(color).add_modifier(if pulse_bright { + Modifier::BOLD + } else { + Modifier::DIM + }); + let bar_style = Style::default().fg(color).add_modifier(Modifier::DIM); + lines.push(Line::from(vec![ + Span::styled("●Think ", name_style), + Span::styled( + format!(" {:<20}", "generating reply"), + Style::default().fg(theme.graph_text), + ), + Span::styled(" ", Style::default()), + Span::styled("█".repeat(bar_fill), bar_style), + Span::styled("░".repeat(bar_empty), Style::default().fg(theme.div_line)), + Span::styled( + format!(" {:>5}…", fmt_duration(thinking_duration)), + Style::default().fg(color), + ), + ])); + } + + f.render_widget(Paragraph::new(lines), area); +} + +pub(crate) fn tool_color(name: &str, theme: &Theme) -> Color { + match name { + "Read" => theme.session_id, + "Edit" => theme.proc_misc, + "Write" => theme.cpu_box, + "Bash" => theme.hi_fg, + "shell" | "exec_command" | "write_stdin" => theme.hi_fg, + "apply_patch" => theme.proc_misc, + "update_plan" => theme.title, + "spawn_agent" | "send_input" | "wait_agent" => theme.title, + "view_image" => theme.session_id, + "Grep" => theme.status_fg, + "Glob" => theme.graph_text, + "find" | "list_mcp_resources" | "read_mcp_resource" => theme.status_fg, + "Agent" => theme.title, + "Skill" => theme.selected_fg, + _ => theme.inactive_fg, + } +} + +pub(crate) fn tool_label(name: &str) -> &str { + match name { + "exec_command" | "shell" => "Exec", + "write_stdin" => "Input", + "apply_patch" => "Patch", + "update_plan" => "Plan", + "spawn_agent" => "Agent", + "send_input" => "Send", + "wait_agent" => "Wait", + "view_image" => "Image", + "list_mcp_resources" | "read_mcp_resource" => "MCP", + other => other, + } +} + +fn fmt_duration(ms: u64) -> String { + if ms >= 60_000 { + format!("{}m{:.0}s", ms / 60_000, (ms % 60_000) as f64 / 1000.0) + } else if ms >= 1000 { + format!("{:.1}s", ms as f64 / 1000.0) + } else { + format!("{}ms", ms) + } +} diff --git a/src/ui/view_menu.rs b/src/ui/view_menu.rs index 1113725..dfce680 100644 --- a/src/ui/view_menu.rs +++ b/src/ui/view_menu.rs @@ -48,29 +48,39 @@ pub(crate) fn items(app: &App) -> Vec { }, ViewItem { key: '2', + label: t("view.quota_panel").leak(), + state: bool_state(app.show_quota), + }, + ViewItem { + key: '3', label: t("view.tokens_panel").leak(), state: bool_state(app.show_tokens), }, ViewItem { - key: '3', + key: '4', label: t("view.projects_panel").leak(), state: bool_state(app.show_projects), }, ViewItem { - key: '4', + key: '5', label: t("view.ports_panel").leak(), state: bool_state(app.show_ports), }, ViewItem { - key: '5', + key: '6', label: t("view.sessions_panel").leak(), state: bool_state(app.show_sessions), }, ViewItem { - key: '6', + key: '7', label: t("view.mcp_servers_panel").leak(), state: bool_state(app.show_mcp), }, + ViewItem { + key: '8', + label: t("view.gpu_panel").leak(), + state: bool_state(app.show_gpu), + }, ViewItem { key: 'M', label: t("view.mcp_session_hide").leak(),