feat: add ExternalUsage segment with provider-based extensibility#104
feat: add ExternalUsage segment with provider-based extensibility#104yearth wants to merge 6 commits intoHaleclipse:masterfrom
Conversation
Replaces the per-provider MiniMaxTokenPlan approach with a general-purpose ExternalUsage segment that dispatches to pluggable UsageProvider impls. Design: - `UsageProvider` trait: name() + fetch(options) -> Vec<UsageMetric> - `ExternalUsageSegment`: handles caching, circle-icon formatting, config - `MinimaxProvider`: queries MiniMax coding_plan/remains API - Shared `Cache` utility extracted to cache.rs Adding a new provider only requires implementing UsageProvider and registering it in providers/mod.rs — no changes to segment core or types. Default config (disabled, provider=minimax): options.provider = "minimax" options.auth_env = "MINIMAX_API_KEY" options.cache_duration = 300 options.timeout = 10 Supersedes Haleclipse#101 and Haleclipse#102 (MiniMaxTokenPlan).
Reviewer's GuideIntroduce a new extensible ExternalUsage statusline segment that fetches quota/usage metrics via pluggable providers (initially MiniMax), caches responses on disk, and wires the segment through configuration, statusline collection, themes, and the TUI preview/settings flows. Sequence diagram for ExternalUsageSegment collection with cache and MiniMax providersequenceDiagram
actor User
participant TUI as TUI_Statusline
participant Statusline as Statusline_collect_all_segments
participant Segment as ExternalUsageSegment
participant Config as Config
participant Providers as Providers_build
participant Cache as Cache
participant Provider as MinimaxProvider
participant MiniMaxAPI as MiniMax_HTTP_API
User->>TUI: trigger_statusline_refresh
TUI->>Statusline: collect_all_segments(input)
Statusline->>Segment: collect(input)
Segment->>Config: load()
Config-->>Segment: Config
Segment->>Config: find SegmentConfig with id ExternalUsage
Config-->>Segment: SegmentConfig(options)
Segment->>Providers: build(provider_name)
Providers-->>Segment: MinimaxProvider
Segment->>Cache: new(provider_name)
Cache-->>Segment: Cache
Segment->>Cache: load()
alt cache_entry_exists
Cache-->>Segment: CacheEntry
Segment->>Cache: is_valid(CacheEntry, cache_duration)
alt cache_valid
Segment->>Segment: cached_to_usage(CacheEntry)
Segment-->>Segment: metrics_from_cache
else cache_expired
Segment->>Provider: fetch(options)
alt provider_fetch_success
Provider->>MiniMaxAPI: GET /coding_plan/remains
MiniMaxAPI-->>Provider: ApiResponse JSON
Provider-->>Segment: metrics
Segment->>Cache: save(CacheEntry_from_metrics)
else provider_fetch_failure
Segment->>Segment: cached_to_usage(CacheEntry)
Segment-->>Segment: metrics_from_stale_cache
end
end
else no_cache_entry
Cache-->>Segment: None
Segment->>Provider: fetch(options)
alt provider_fetch_success
Provider->>MiniMaxAPI: GET /coding_plan/remains
MiniMaxAPI-->>Provider: ApiResponse JSON
Provider-->>Segment: metrics
Segment->>Cache: save(CacheEntry_from_metrics)
else provider_fetch_failure
Segment-->>Statusline: None
Statusline-->>TUI: omit_external_usage_segment
TUI-->>User: statusline_without_external_usage
end
end
Segment->>Segment: format primary string and metadata
Segment-->>Statusline: SegmentData
Statusline-->>TUI: aggregated_segments
TUI-->>User: statusline_with_external_usage
Class diagram for ExternalUsage provider-based extensibilityclassDiagram
class Segment {
<<trait>>
collect(input~InputData~) SegmentData
id() SegmentId
}
class ExternalUsageSegment {
+new() ExternalUsageSegment
+collect(input~InputData~) SegmentData
+id() SegmentId
-get_circle_icon(remaining_pct~u32~) &str
}
class Cache {
-path PathBuf
+new(provider_name~&str~) Cache
+load() CacheEntry
+save(entry~CacheEntry~) void
+is_valid(entry~CacheEntry~, cache_duration_secs~u64~) bool
}
class CacheEntry {
+metrics Vec~CachedMetric~
+cached_at String
}
class CachedMetric {
+label String
+remaining_pct u32
}
class UsageMetric {
+label String
+remaining_pct u32
}
class UsageProvider {
<<trait>>
+name() &str
+fetch(options~HashMap~String,Value~~) Vec~UsageMetric~
}
class MinimaxProvider {
+name() &str
+fetch(options~HashMap~String,Value~~) Vec~UsageMetric~
-fetch_api(api_key~&str~, timeout_secs~u64~) ApiResponse
-remaining_pct(usage~u64~, total~u64~) u32
}
class ApiResponse {
+model_remains Vec~ModelRemain~
}
class ModelRemain {
+model_name String
+current_interval_total_count u64
+current_interval_usage_count u64
+current_weekly_total_count u64
+current_weekly_usage_count u64
}
class ProvidersModule {
+build(provider_name~&str~) UsageProvider
}
class Config {
+segments Vec~SegmentConfig~
+load() Config
}
class SegmentConfig {
+id SegmentId
+enabled bool
+options HashMap~String,Value~
}
class ThemePresets {
+default_external_usage_segment() SegmentConfig
}
class Statusline {
+collect_all_segments(input~InputData~) Vec~SegmentData~
}
Segment <|.. ExternalUsageSegment
UsageProvider <|.. MinimaxProvider
ExternalUsageSegment --> Cache : uses
ExternalUsageSegment --> UsageProvider : uses
ExternalUsageSegment --> UsageMetric : formats
ExternalUsageSegment --> Config : loads
ExternalUsageSegment --> SegmentConfig : reads options
ExternalUsageSegment --> ProvidersModule : build
CacheEntry --> CachedMetric : contains
Cache --> CacheEntry : reads_writes
MinimaxProvider --> ApiResponse : parses
ApiResponse --> ModelRemain : contains
ThemePresets --> SegmentConfig : builds_default
Statusline --> ExternalUsageSegment : dispatches
Config --> SegmentConfig : contains
SegmentConfig --> SegmentId : identifies
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- The
external_usage_segmentdefinitions in all the theme_* files are essentially identical (including the options map); consider extracting a shared helper or defaultSegmentConfigbuilder so defaults stay in sync and are easier to update. - In
ExternalUsageSegment::collectyou reload the globalConfigand search for the segment on every call; if the existing segment framework allows it, consider passing the segment’s options viaInputDataor caching the resolved config to avoid repeated disk reads on each render.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The `external_usage_segment` definitions in all the theme_* files are essentially identical (including the options map); consider extracting a shared helper or default `SegmentConfig` builder so defaults stay in sync and are easier to update.
- In `ExternalUsageSegment::collect` you reload the global `Config` and search for the segment on every call; if the existing segment framework allows it, consider passing the segment’s options via `InputData` or caching the resolved config to avoid repeated disk reads on each render.
## Individual Comments
### Comment 1
<location path="src/core/segments/external_usage/mod.rs" line_range="60-63" />
<code_context>
+ let provider = providers::build(provider_name)?;
+ let cache = Cache::new(provider_name)?;
+
+ // 尝试使用有效缓存
+ let metrics = if let Some(entry) = cache.load() {
+ if Cache::is_valid(&entry, cache_duration) {
+ entry.metrics.iter().map(|m| providers::UsageMetric {
+ label: m.label.clone(),
+ remaining_pct: m.remaining_pct,
</code_context>
<issue_to_address>
**suggestion:** There is duplicated mapping logic between `CachedMetric` and `UsageMetric` that could be centralized.
The `CachedMetric` → `UsageMetric` conversion is duplicated in both the valid-cache path and the “fetch failed, fall back to cache” path. Please extract a helper (e.g. `fn cached_to_usage(entry: &CacheEntry) -> Vec<UsageMetric>`) so changes to the cached or metric structure only need to be made in one place.
Suggested implementation:
```rust
fn cached_to_usage(entry: &CacheEntry) -> Vec<providers::UsageMetric> {
entry
.metrics
.iter()
.map(|m| providers::UsageMetric {
label: m.label.clone(),
remaining_pct: m.remaining_pct,
})
.collect()
}
let cache_duration = options
```
```rust
// 尝试使用有效缓存
let metrics = if let Some(entry) = cache.load() {
if Cache::is_valid(&entry, cache_duration) {
cached_to_usage(&entry)
```
There is likely a second occurrence of the same mapping logic in the “fetch failed, fall back to cache” path, something like:
```rust
entry.metrics.iter().map(|m| providers::UsageMetric {
label: m.label.clone(),
remaining_pct: m.remaining_pct,
}).collect()
```
That should also be replaced with:
```rust
cached_to_usage(&entry)
```
to fully centralize the conversion logic.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
…licate theme configs, translate comments to English
|
@sourcery-ai review |
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- ExternalUsageSegment::collect() reloads the entire Config from disk on every call; consider passing the config (or just the segment options) in via InputData or a shared context to avoid repeated IO on each render.
- get_circle_icon() allocates a new String on every call even though the icons are static; returning &'static str (and only allocating at the formatting/join callsite) would reduce per-render allocations.
- UsageProvider::fetch() returns Option, which makes it hard to distinguish different failure modes (network vs auth vs parse); using Result with a concrete error type (or at least an error string) would make provider failures easier to diagnose and handle.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- ExternalUsageSegment::collect() reloads the entire Config from disk on every call; consider passing the config (or just the segment options) in via InputData or a shared context to avoid repeated IO on each render.
- get_circle_icon() allocates a new String on every call even though the icons are static; returning &'static str (and only allocating at the formatting/join callsite) would reduce per-render allocations.
- UsageProvider::fetch() returns Option, which makes it hard to distinguish different failure modes (network vs auth vs parse); using Result with a concrete error type (or at least an error string) would make provider failures easier to diagnose and handle.
## Individual Comments
### Comment 1
<location path="src/core/segments/external_usage/providers/minimax.rs" line_range="98-104" />
<code_context>
+ }
+}
+
+fn remaining_pct(usage: u64, total: u64) -> u32 {
+ if total == 0 {
+ return 100;
+ }
+ let used_pct = (usage as f64 / total as f64 * 100.0) as u32;
+ 100u32.saturating_sub(used_pct)
+}
</code_context>
<issue_to_address>
**suggestion (bug_risk):** The percentage calculation uses floating point where integer math could be simpler and avoid precision issues.
`used_pct` is computed via `f64` and cast back to `u32`, which can lose precision for large `usage`/`total` values. You can keep this in integer math instead, e.g. `let used_pct = usage.saturating_mul(100) / total;`, which avoids floating point and naturally clamps on overflow. With `100u32.saturating_sub(used_pct)` this still behaves correctly when `usage > total`.
```suggestion
fn remaining_pct(usage: u64, total: u64) -> u32 {
if total == 0 {
return 100;
}
let used_pct = (usage.saturating_mul(100) / total) as u32;
100u32.saturating_sub(used_pct)
}
```
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
|
Thanks for the updated review. Addressed the On the other two points:
|
|
@sourcery-ai review |
There was a problem hiding this comment.
Hey - I've found 1 issue, and left some high level feedback:
- ExternalUsageSegment::collect() calls Config::load() on every render, which adds disk I/O to the hot path; consider passing the config via InputData (as other segments do) or otherwise caching it to avoid repeatedly loading from disk.
- The cache file is keyed only by provider_name, so changing options like auth_env or preferred_model while keeping the same provider will continue using the old cache; consider incorporating a hash of the relevant options into the cache key or invalidating the cache when these options change.
- The provider trait module (providers/mod.rs) uses Chinese comments in an otherwise English codebase; consider standardizing comment language for consistency with the rest of the project.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- ExternalUsageSegment::collect() calls Config::load() on every render, which adds disk I/O to the hot path; consider passing the config via InputData (as other segments do) or otherwise caching it to avoid repeatedly loading from disk.
- The cache file is keyed only by provider_name, so changing options like auth_env or preferred_model while keeping the same provider will continue using the old cache; consider incorporating a hash of the relevant options into the cache key or invalidating the cache when these options change.
- The provider trait module (providers/mod.rs) uses Chinese comments in an otherwise English codebase; consider standardizing comment language for consistency with the rest of the project.
## Individual Comments
### Comment 1
<location path="src/core/segments/external_usage/cache.rs" line_range="47-53" />
<code_context>
+ }
+ }
+
+ pub fn is_valid(entry: &CacheEntry, cache_duration_secs: u64) -> bool {
+ DateTime::parse_from_rfc3339(&entry.cached_at)
+ .map(|cached_at| {
+ let elapsed = Utc::now().signed_duration_since(cached_at.with_timezone(&Utc));
+ elapsed.num_seconds() < cache_duration_secs as i64
+ })
+ .unwrap_or(false)
+ }
+}
</code_context>
<issue_to_address>
**issue (bug_risk):** Future-dated cache entries are treated as valid, which may not be intended.
If `cached_at` is in the future (e.g. from clock skew), `elapsed` is negative and the comparison will always treat the entry as valid. To avoid effectively never-expiring entries, consider rejecting future timestamps (e.g. return `false` when `elapsed.num_seconds().is_negative()`) or clamp negative elapsed values to zero before comparing.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
SourceryAI
left a comment
There was a problem hiding this comment.
Hey - I've left some high level feedback:
- The cache path in
Cache::newis hardcoded to~/.claude/ccline/...; consider reusing whatever central config/cache directory helper the rest of the app uses so this segment follows the same location and any future directory layout changes automatically apply here. - In
ExternalUsageSegment::collect, metadata keys are derived directly fromlabel(e.g.,format!("{}_remaining_pct", m.label)); it would be safer to normalize or restrict label characters before using them as keys to avoid surprises if a provider ever returns labels with spaces or non-ASCII characters.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The cache path in `Cache::new` is hardcoded to `~/.claude/ccline/...`; consider reusing whatever central config/cache directory helper the rest of the app uses so this segment follows the same location and any future directory layout changes automatically apply here.
- In `ExternalUsageSegment::collect`, metadata keys are derived directly from `label` (e.g., `format!("{}_remaining_pct", m.label)`); it would be safer to normalize or restrict label characters before using them as keys to avoid surprises if a provider ever returns labels with spaces or non-ASCII characters.Hi @yearth! 👋
Thanks for trying out Sourcery by commenting with @sourcery-ai review! 🚀
Install the sourcery-ai bot to get automatic code reviews on every pull request ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.…slate comments to English
|
Addressed the latest review:
On the hardcoded cache path: the rest of the codebase (loader.rs, updater.rs, models.rs, presets.rs) all construct |
Background
This PR supersedes #101 and #102. Based on your feedback that we should not create a separate Usage Segment for any single provider, I redesigned the approach around a unified extension mechanism.
Design
A new
ExternalUsagesegment that dispatches to pluggableUsageProviderimplementations:The
UsageProvidertrait is minimal:Adding a new provider only requires a new file + one line in the registry. No changes to segment core, config types, or UI.
Why not JSONPath / generic field extraction?
A JSONPath-based approach (configuring how to extract fields from arbitrary JSON responses) was considered but rejected for a few reasons:
Different APIs have fundamentally different shapes. MiniMax returns an array of model objects where you need to filter by model name, then compute
(total - used) / total. A generic path expression can't express this computation — it would need a mini query language, not just field access.Complexity vs. benefit tradeoff. A JSONPath extractor would be complex to implement, hard to validate in the TUI config editor, and brittle against API changes. A typed provider struct handles edge cases (zero total, fallback to first model, etc.) naturally.
Providers are rare, not frequent. The number of external quota APIs users would realistically want to monitor is small. Adding a provider is a 50-line Rust file — a reasonable contribution cost.
Configuration (MiniMax example)
{ "id": "external_usage", "enabled": true, "options": { "provider": "minimax", "auth_env": "MINIMAX_API_KEY", "cache_duration": 300, "timeout": 10 } }Tests
12 unit tests covering cache validity, percentage calculation edge cases (zero total, over-used), and circle icon boundary values. All passing.
Summary by Sourcery
Introduce an extensible External Usage statusline segment that displays remaining quota metrics from external providers and wire it into configuration, theming, and UI preview.
New Features:
Enhancements:
Tests: