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
+[](https://github.com/weby-homelab/LLMtop/actions/workflows/ci.yml)
+[](LICENSE)
+[](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(),