chore: import upstream snapshot with attribution
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,926 @@
|
||||
use anyhow::Result;
|
||||
use crossterm::event::{KeyCode, KeyModifiers};
|
||||
use ratatui::{
|
||||
prelude::*,
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub enum UsageOverlayStatus {
|
||||
Loading,
|
||||
Good,
|
||||
Warning,
|
||||
Critical,
|
||||
Error,
|
||||
Info,
|
||||
}
|
||||
|
||||
impl UsageOverlayStatus {
|
||||
pub fn label_for_display(self) -> &'static str {
|
||||
self.label()
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Loading => "loading",
|
||||
Self::Good => "healthy",
|
||||
Self::Warning => "watch",
|
||||
Self::Critical => "high",
|
||||
Self::Error => "error",
|
||||
Self::Info => "info",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn color(self) -> Color {
|
||||
match self {
|
||||
Self::Loading => Color::Rgb(129, 184, 255),
|
||||
Self::Good => Color::Rgb(111, 214, 181),
|
||||
Self::Warning => Color::Rgb(255, 196, 112),
|
||||
Self::Critical => Color::Rgb(255, 146, 110),
|
||||
Self::Error => Color::Rgb(232, 134, 134),
|
||||
Self::Info => Color::Rgb(196, 170, 255),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(self) -> &'static str {
|
||||
match self {
|
||||
Self::Loading => "◌",
|
||||
Self::Good => "●",
|
||||
Self::Warning => "▲",
|
||||
Self::Critical => "◆",
|
||||
Self::Error => "✕",
|
||||
Self::Info => "○",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct UsageOverlayItem {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub subtitle: String,
|
||||
pub status: UsageOverlayStatus,
|
||||
pub detail_lines: Vec<String>,
|
||||
}
|
||||
|
||||
impl UsageOverlayItem {
|
||||
pub fn new(
|
||||
id: impl Into<String>,
|
||||
title: impl Into<String>,
|
||||
subtitle: impl Into<String>,
|
||||
status: UsageOverlayStatus,
|
||||
detail_lines: Vec<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
subtitle: subtitle.into(),
|
||||
status,
|
||||
detail_lines,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
|
||||
pub struct UsageOverlaySummary {
|
||||
pub provider_count: usize,
|
||||
pub warning_count: usize,
|
||||
pub critical_count: usize,
|
||||
pub error_count: usize,
|
||||
pub session_visible: bool,
|
||||
}
|
||||
|
||||
pub fn item_matches_filter(item: &UsageOverlayItem, filter: &str) -> bool {
|
||||
if filter.is_empty() {
|
||||
return true;
|
||||
}
|
||||
|
||||
let haystack = format!(
|
||||
"{} {} {} {} {}",
|
||||
item.id,
|
||||
item.title,
|
||||
item.subtitle,
|
||||
item.status.label(),
|
||||
item.detail_lines.join(" ")
|
||||
)
|
||||
.to_lowercase();
|
||||
|
||||
filter
|
||||
.split_whitespace()
|
||||
.all(|needle| haystack.contains(&needle.to_lowercase()))
|
||||
}
|
||||
|
||||
const PANEL_BG: Color = Color::Rgb(24, 28, 40);
|
||||
const PANEL_BORDER: Color = Color::Rgb(90, 95, 110);
|
||||
const PANEL_BORDER_ACTIVE: Color = Color::Rgb(120, 140, 190);
|
||||
const SECTION_BORDER: Color = Color::Rgb(70, 78, 94);
|
||||
const SELECTED_BG: Color = Color::Rgb(38, 42, 56);
|
||||
const MUTED: Color = Color::Rgb(140, 146, 163);
|
||||
const MUTED_DARK: Color = Color::Rgb(100, 106, 122);
|
||||
const OVERLAY_PERCENT_X: u16 = 88;
|
||||
const OVERLAY_PERCENT_Y: u16 = 74;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UsageOverlay {
|
||||
title: String,
|
||||
items: Vec<UsageOverlayItem>,
|
||||
filtered: Vec<usize>,
|
||||
selected: usize,
|
||||
filter: String,
|
||||
summary: UsageOverlaySummary,
|
||||
}
|
||||
|
||||
pub enum OverlayAction {
|
||||
Continue,
|
||||
Close,
|
||||
}
|
||||
|
||||
impl UsageOverlay {
|
||||
pub fn loading() -> Self {
|
||||
Self::new(
|
||||
" Usage ",
|
||||
vec![UsageOverlayItem::new(
|
||||
"loading",
|
||||
"Refreshing usage",
|
||||
"Fetching limits from connected providers",
|
||||
UsageOverlayStatus::Loading,
|
||||
vec![
|
||||
"Fetching usage limits from all connected providers...".to_string(),
|
||||
"".to_string(),
|
||||
"This view will update automatically when the usage report returns."
|
||||
.to_string(),
|
||||
],
|
||||
)],
|
||||
UsageOverlaySummary::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn from_progress(progress: &jcode_usage_types::ProviderUsageProgress) -> Self {
|
||||
Self::from_provider_reports(
|
||||
&progress.results,
|
||||
!progress.done,
|
||||
progress.completed,
|
||||
progress.total,
|
||||
progress.from_cache,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn from_provider_reports(
|
||||
reports: &[jcode_usage_types::ProviderUsage],
|
||||
refreshing: bool,
|
||||
completed: usize,
|
||||
total: usize,
|
||||
from_cache: bool,
|
||||
) -> Self {
|
||||
let mut items: Vec<UsageOverlayItem> = reports.iter().map(provider_item).collect();
|
||||
|
||||
if refreshing {
|
||||
let subtitle = if total > 0 {
|
||||
format!("Refreshing providers ({}/{})", completed.min(total), total)
|
||||
} else if from_cache {
|
||||
"Showing cached usage while refreshing providers".to_string()
|
||||
} else {
|
||||
"Fetching usage limits from connected providers".to_string()
|
||||
};
|
||||
items.push(UsageOverlayItem::new(
|
||||
"refreshing",
|
||||
"Refreshing usage",
|
||||
subtitle,
|
||||
UsageOverlayStatus::Loading,
|
||||
vec![
|
||||
"## Live refresh".to_string(),
|
||||
if from_cache {
|
||||
"• Cached results are visible immediately.".to_string()
|
||||
} else {
|
||||
"• Waiting for provider responses.".to_string()
|
||||
},
|
||||
if total > 0 {
|
||||
format!(
|
||||
"• Completed {}/{} provider checks.",
|
||||
completed.min(total),
|
||||
total
|
||||
)
|
||||
} else {
|
||||
"• Discovering connected providers.".to_string()
|
||||
},
|
||||
"• This panel updates as each provider returns.".to_string(),
|
||||
],
|
||||
));
|
||||
} else if items.is_empty() {
|
||||
items.push(UsageOverlayItem::new(
|
||||
"no-providers",
|
||||
"No connected providers",
|
||||
"Connect Claude or OpenAI OAuth to show usage limits",
|
||||
UsageOverlayStatus::Info,
|
||||
vec![
|
||||
"## No usage sources found".to_string(),
|
||||
"• No providers with OAuth credentials were found.".to_string(),
|
||||
"• Use `/login claude` or `/login openai` to connect a provider.".to_string(),
|
||||
"• Then run `/usage` again.".to_string(),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
let mut summary = UsageOverlaySummary {
|
||||
provider_count: reports.len(),
|
||||
session_visible: false,
|
||||
..UsageOverlaySummary::default()
|
||||
};
|
||||
for report in reports {
|
||||
match provider_status(report) {
|
||||
UsageOverlayStatus::Warning => summary.warning_count += 1,
|
||||
UsageOverlayStatus::Critical => summary.critical_count += 1,
|
||||
UsageOverlayStatus::Error => summary.error_count += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let title = if refreshing {
|
||||
" Usage · refreshing "
|
||||
} else {
|
||||
" Usage "
|
||||
};
|
||||
Self::new(title, items, summary)
|
||||
}
|
||||
|
||||
pub fn debug_memory_profile(&self) -> serde_json::Value {
|
||||
let items_estimate_bytes: usize = self.items.iter().map(estimate_item_bytes).sum();
|
||||
let filtered_estimate_bytes = self.filtered.capacity() * std::mem::size_of::<usize>();
|
||||
let filter_bytes = self.filter.capacity();
|
||||
let title_bytes = self.title.capacity();
|
||||
let total_estimate_bytes =
|
||||
items_estimate_bytes + filtered_estimate_bytes + filter_bytes + title_bytes;
|
||||
|
||||
serde_json::json!({
|
||||
"items_count": self.items.len(),
|
||||
"filtered_count": self.filtered.len(),
|
||||
"selected": self.selected,
|
||||
"title_bytes": title_bytes,
|
||||
"filter_bytes": filter_bytes,
|
||||
"items_estimate_bytes": items_estimate_bytes,
|
||||
"filtered_estimate_bytes": filtered_estimate_bytes,
|
||||
"total_estimate_bytes": total_estimate_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
title: impl Into<String>,
|
||||
items: Vec<UsageOverlayItem>,
|
||||
summary: UsageOverlaySummary,
|
||||
) -> Self {
|
||||
let mut overlay = Self {
|
||||
title: title.into(),
|
||||
items,
|
||||
filtered: Vec::new(),
|
||||
selected: 0,
|
||||
filter: String::new(),
|
||||
summary,
|
||||
};
|
||||
overlay.apply_filter();
|
||||
overlay
|
||||
}
|
||||
|
||||
pub fn selected_item_title(&self) -> Option<&str> {
|
||||
self.selected_item().map(|item| item.title.as_str())
|
||||
}
|
||||
|
||||
pub fn replace_preserving_view(&mut self, mut next: Self) {
|
||||
let selected_id = self.selected_item().map(|item| item.id.clone());
|
||||
next.filter = self.filter.clone();
|
||||
next.apply_filter();
|
||||
if let Some(selected_id) = selected_id
|
||||
&& let Some(selected) = next
|
||||
.filtered
|
||||
.iter()
|
||||
.position(|item_idx| next.items[*item_idx].id == selected_id)
|
||||
{
|
||||
next.selected = selected;
|
||||
}
|
||||
*self = next;
|
||||
}
|
||||
|
||||
pub fn selected_item_detail_text(&self) -> String {
|
||||
self.selected_item()
|
||||
.map(|item| item.detail_lines.join("\n"))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn selected_item(&self) -> Option<&UsageOverlayItem> {
|
||||
self.filtered
|
||||
.get(self.selected)
|
||||
.and_then(|idx| self.items.get(*idx))
|
||||
}
|
||||
|
||||
fn apply_filter(&mut self) {
|
||||
self.filtered = self
|
||||
.items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(idx, item)| item_matches_filter(item, &self.filter).then_some(idx))
|
||||
.collect();
|
||||
if self.selected >= self.filtered.len() {
|
||||
self.selected = self.filtered.len().saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_overlay_key(
|
||||
&mut self,
|
||||
code: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
) -> Result<OverlayAction> {
|
||||
match code {
|
||||
KeyCode::Esc => {
|
||||
if !self.filter.is_empty() {
|
||||
self.filter.clear();
|
||||
self.apply_filter();
|
||||
return Ok(OverlayAction::Continue);
|
||||
}
|
||||
return Ok(OverlayAction::Close);
|
||||
}
|
||||
KeyCode::Char('q') if !modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
return Ok(OverlayAction::Close);
|
||||
}
|
||||
KeyCode::Char('c') if modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
return Ok(OverlayAction::Close);
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.selected = self.selected.saturating_sub(1);
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
let max = self.filtered.len().saturating_sub(1);
|
||||
self.selected = (self.selected + 1).min(max);
|
||||
}
|
||||
KeyCode::PageUp | KeyCode::Char('K') => {
|
||||
self.selected = self.selected.saturating_sub(6);
|
||||
}
|
||||
KeyCode::PageDown | KeyCode::Char('J') => {
|
||||
let max = self.filtered.len().saturating_sub(1);
|
||||
self.selected = (self.selected + 6).min(max);
|
||||
}
|
||||
KeyCode::Home | KeyCode::Char('g') => {
|
||||
self.selected = 0;
|
||||
}
|
||||
KeyCode::End | KeyCode::Char('G') => {
|
||||
self.selected = self.filtered.len().saturating_sub(1);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if self.filter.pop().is_some() {
|
||||
self.apply_filter();
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c)
|
||||
if !modifiers.contains(KeyModifiers::CONTROL)
|
||||
&& !modifiers.contains(KeyModifiers::ALT) =>
|
||||
{
|
||||
self.filter.push(c);
|
||||
self.apply_filter();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(OverlayAction::Continue)
|
||||
}
|
||||
|
||||
pub fn render(&self, frame: &mut Frame) {
|
||||
let area = centered_rect(OVERLAY_PERCENT_X, OVERLAY_PERCENT_Y, frame.area());
|
||||
|
||||
let block = Block::default()
|
||||
.title(format!(" {} ", self.title))
|
||||
.title_bottom(Line::from(vec![
|
||||
hotkey(" Up/Down "),
|
||||
Span::styled(" navigate ", Style::default().fg(MUTED_DARK)),
|
||||
hotkey(" type "),
|
||||
Span::styled(" filter ", Style::default().fg(MUTED_DARK)),
|
||||
hotkey(" /usage "),
|
||||
Span::styled(" refresh ", Style::default().fg(MUTED_DARK)),
|
||||
hotkey(" Esc "),
|
||||
Span::styled(" clear / close ", Style::default().fg(MUTED_DARK)),
|
||||
]))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(PANEL_BORDER));
|
||||
frame.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 rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(5),
|
||||
Constraint::Min(10),
|
||||
Constraint::Length(2),
|
||||
])
|
||||
.split(inner);
|
||||
|
||||
self.render_header(frame, rows[0]);
|
||||
|
||||
let body = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(39), Constraint::Percentage(61)])
|
||||
.split(rows[1]);
|
||||
|
||||
self.render_item_list(frame, body[0]);
|
||||
self.render_detail_pane(frame, body[1]);
|
||||
|
||||
let footer = Paragraph::new(Line::from(vec![
|
||||
Span::styled("Focus ", Style::default().fg(MUTED_DARK)),
|
||||
Span::styled(
|
||||
"Use this panel to compare provider headroom and reset times without cluttering the chat transcript.",
|
||||
Style::default().fg(MUTED),
|
||||
),
|
||||
]));
|
||||
frame.render_widget(footer, rows[2]);
|
||||
}
|
||||
|
||||
fn render_header(&self, frame: &mut Frame, area: Rect) {
|
||||
let block = Block::default()
|
||||
.title(Span::styled(
|
||||
" Usage overview ",
|
||||
Style::default().fg(Color::White).bold(),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().bg(PANEL_BG))
|
||||
.border_style(Style::default().fg(SECTION_BORDER));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let lines = vec![
|
||||
Line::from(vec![
|
||||
Span::styled("Filter ", Style::default().fg(MUTED_DARK)),
|
||||
Span::styled(
|
||||
if self.filter.is_empty() {
|
||||
"type provider or plan name".to_string()
|
||||
} else {
|
||||
self.filter.clone()
|
||||
},
|
||||
if self.filter.is_empty() {
|
||||
Style::default().fg(Color::Gray).italic()
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
},
|
||||
),
|
||||
Span::styled(
|
||||
format!(" · {} results", self.filtered.len()),
|
||||
Style::default().fg(MUTED_DARK),
|
||||
),
|
||||
]),
|
||||
Line::from(vec![
|
||||
metric_span(
|
||||
"providers",
|
||||
self.summary.provider_count,
|
||||
Color::Rgb(111, 214, 181),
|
||||
),
|
||||
Span::raw(" "),
|
||||
metric_span(
|
||||
"watch",
|
||||
self.summary.warning_count,
|
||||
Color::Rgb(255, 196, 112),
|
||||
),
|
||||
Span::raw(" "),
|
||||
metric_span(
|
||||
"high",
|
||||
self.summary.critical_count,
|
||||
Color::Rgb(255, 146, 110),
|
||||
),
|
||||
Span::raw(" "),
|
||||
metric_span(
|
||||
"errors",
|
||||
self.summary.error_count,
|
||||
Color::Rgb(232, 134, 134),
|
||||
),
|
||||
if self.summary.session_visible {
|
||||
Span::styled(" · session included", Style::default().fg(MUTED_DARK))
|
||||
} else {
|
||||
Span::raw("")
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
|
||||
}
|
||||
|
||||
fn render_item_list(&self, frame: &mut Frame, area: Rect) {
|
||||
let title = if self.filtered.is_empty() {
|
||||
" Sources ".to_string()
|
||||
} else {
|
||||
format!(" Sources ({}/{}) ", self.selected + 1, self.filtered.len())
|
||||
};
|
||||
let block = Block::default()
|
||||
.title(Span::styled(
|
||||
title,
|
||||
Style::default().fg(Color::White).bold(),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().bg(PANEL_BG))
|
||||
.border_style(Style::default().fg(PANEL_BORDER_ACTIVE));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
if self.filtered.is_empty() {
|
||||
frame.render_widget(
|
||||
Paragraph::new("No usage items match the current filter.")
|
||||
.style(Style::default().fg(MUTED))
|
||||
.wrap(Wrap { trim: false }),
|
||||
inner,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut selected_line = 0usize;
|
||||
for (visible_idx, item_idx) in self.filtered.iter().enumerate() {
|
||||
let item = &self.items[*item_idx];
|
||||
let selected = visible_idx == self.selected;
|
||||
if selected {
|
||||
selected_line = lines.len();
|
||||
}
|
||||
let title_style = if selected {
|
||||
Style::default().fg(Color::White).bg(SELECTED_BG).bold()
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
let subtitle_style = if selected {
|
||||
Style::default().fg(MUTED).bg(SELECTED_BG)
|
||||
} else {
|
||||
Style::default().fg(MUTED)
|
||||
};
|
||||
let badge_style = Style::default()
|
||||
.fg(item.status.color())
|
||||
.bg(if selected { SELECTED_BG } else { PANEL_BG })
|
||||
.bold();
|
||||
let marker = if selected { "›" } else { " " };
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{} {} ", marker, item.status.icon()),
|
||||
Style::default().fg(item.status.color()).bg(if selected {
|
||||
SELECTED_BG
|
||||
} else {
|
||||
PANEL_BG
|
||||
}),
|
||||
),
|
||||
Span::styled(
|
||||
truncate_with_ellipsis(&item.title, inner.width.saturating_sub(16) as usize),
|
||||
title_style,
|
||||
),
|
||||
Span::raw(" "),
|
||||
Span::styled(format!("[{}]", item.status.label()), badge_style),
|
||||
]));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" {}", item.subtitle),
|
||||
subtitle_style,
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
|
||||
let visible_height = inner.height.max(1) as usize;
|
||||
let scroll = selected_line.saturating_sub(visible_height.saturating_sub(3));
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.scroll((scroll.min(u16::MAX as usize) as u16, 0))
|
||||
.wrap(Wrap { trim: false }),
|
||||
inner,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_detail_pane(&self, frame: &mut Frame, area: Rect) {
|
||||
let selected = self.selected_item();
|
||||
let title = selected
|
||||
.map(|item| format!(" {} · {} ", item.title, item.status.label()))
|
||||
.unwrap_or_else(|| " Usage details ".to_string());
|
||||
let border_color = selected
|
||||
.map(|item| item.status.color())
|
||||
.unwrap_or(PANEL_BORDER_ACTIVE);
|
||||
let block = Block::default()
|
||||
.title(Span::styled(
|
||||
title,
|
||||
Style::default().fg(Color::White).bold(),
|
||||
))
|
||||
.borders(Borders::ALL)
|
||||
.style(Style::default().bg(PANEL_BG))
|
||||
.border_style(Style::default().fg(border_color));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let lines: Vec<Line<'static>> = match selected {
|
||||
Some(item) => item
|
||||
.detail_lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
if line.is_empty() {
|
||||
Line::from("")
|
||||
} else if let Some(rest) = line.strip_prefix("## ") {
|
||||
Line::from(Span::styled(
|
||||
format!(" {}", rest),
|
||||
Style::default().fg(Color::White).bold(),
|
||||
))
|
||||
} else if let Some(rest) = line.strip_prefix("• ") {
|
||||
Line::from(vec![
|
||||
Span::styled(" • ", Style::default().fg(MUTED_DARK)),
|
||||
Span::styled(rest.to_string(), Style::default().fg(MUTED)),
|
||||
])
|
||||
} else {
|
||||
Line::from(Span::styled(line.clone(), Style::default().fg(MUTED)))
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
None => vec![Line::from(Span::styled(
|
||||
"No usage item selected.",
|
||||
Style::default().fg(MUTED),
|
||||
))],
|
||||
};
|
||||
|
||||
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
|
||||
}
|
||||
}
|
||||
|
||||
fn estimate_item_bytes(item: &UsageOverlayItem) -> usize {
|
||||
item.id.capacity()
|
||||
+ item.title.capacity()
|
||||
+ item.subtitle.capacity()
|
||||
+ item
|
||||
.detail_lines
|
||||
.iter()
|
||||
.map(|value| value.capacity())
|
||||
.sum::<usize>()
|
||||
}
|
||||
|
||||
fn hotkey(text: &'static str) -> Span<'static> {
|
||||
Span::styled(text, Style::default().fg(Color::White).bg(Color::DarkGray))
|
||||
}
|
||||
|
||||
fn metric_span(label: &'static str, value: usize, color: Color) -> Span<'static> {
|
||||
Span::styled(
|
||||
format!("{} {}", label, value),
|
||||
Style::default().fg(color).bold(),
|
||||
)
|
||||
}
|
||||
|
||||
fn provider_item(report: &jcode_usage_types::ProviderUsage) -> UsageOverlayItem {
|
||||
let status = provider_status(report);
|
||||
let subtitle = provider_subtitle(report);
|
||||
UsageOverlayItem::new(
|
||||
report.provider_name.clone(),
|
||||
report.provider_name.clone(),
|
||||
subtitle,
|
||||
status,
|
||||
provider_detail_lines(report),
|
||||
)
|
||||
}
|
||||
|
||||
fn provider_status(report: &jcode_usage_types::ProviderUsage) -> UsageOverlayStatus {
|
||||
if report.error.is_some() {
|
||||
return UsageOverlayStatus::Error;
|
||||
}
|
||||
if report.hard_limit_reached {
|
||||
return UsageOverlayStatus::Critical;
|
||||
}
|
||||
let max_percent = report
|
||||
.limits
|
||||
.iter()
|
||||
.map(|limit| limit.usage_percent)
|
||||
.fold(0.0_f32, f32::max);
|
||||
if max_percent >= 90.0 {
|
||||
UsageOverlayStatus::Critical
|
||||
} else if max_percent >= 70.0 {
|
||||
UsageOverlayStatus::Warning
|
||||
} else if report.limits.is_empty() && report.extra_info.is_empty() {
|
||||
UsageOverlayStatus::Info
|
||||
} else {
|
||||
UsageOverlayStatus::Good
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_subtitle(report: &jcode_usage_types::ProviderUsage) -> String {
|
||||
if let Some(error) = &report.error {
|
||||
return truncate_with_ellipsis(error, 72);
|
||||
}
|
||||
if report.hard_limit_reached {
|
||||
return "Hard limit reached".to_string();
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
if let Some(limit) = report
|
||||
.limits
|
||||
.iter()
|
||||
.max_by(|a, b| a.usage_percent.total_cmp(&b.usage_percent))
|
||||
{
|
||||
let mut part = format!(
|
||||
"{} {:.0}% used",
|
||||
limit.name,
|
||||
limit.usage_percent.clamp(0.0, 999.0)
|
||||
);
|
||||
if let Some(reset) = limit.resets_at.as_deref() {
|
||||
part.push_str(&format!(" · resets in {}", format_reset_time(reset)));
|
||||
}
|
||||
parts.push(part);
|
||||
}
|
||||
if let Some((key, value)) = report.extra_info.first() {
|
||||
parts.push(format!("{}: {}", key, value));
|
||||
}
|
||||
if parts.is_empty() {
|
||||
"No usage data available".to_string()
|
||||
} else {
|
||||
truncate_with_ellipsis(&parts.join(" · "), 96)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_reset_timestamp(timestamp: &str) -> Option<chrono::DateTime<chrono::Utc>> {
|
||||
if let Ok(reset) = chrono::DateTime::parse_from_rfc3339(timestamp) {
|
||||
Some(reset.with_timezone(&chrono::Utc))
|
||||
} else if let Ok(reset) =
|
||||
chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%dT%H:%M:%S%.fZ")
|
||||
{
|
||||
Some(reset.and_utc())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_reset_time(timestamp: &str) -> String {
|
||||
if let Some(reset) = parse_reset_timestamp(timestamp) {
|
||||
let duration = reset.signed_duration_since(chrono::Utc::now());
|
||||
if duration.num_seconds() <= 0 {
|
||||
return "now".to_string();
|
||||
}
|
||||
if duration.num_seconds() < 60 {
|
||||
return "1m".to_string();
|
||||
}
|
||||
let days = duration.num_days();
|
||||
let hours = duration.num_hours() % 24;
|
||||
let minutes = duration.num_minutes() % 60;
|
||||
if days > 0 {
|
||||
if hours > 0 {
|
||||
format!("{}d {}h", days, hours)
|
||||
} else if minutes > 0 {
|
||||
format!("{}d {}m", days, minutes)
|
||||
} else {
|
||||
format!("{}d", days)
|
||||
}
|
||||
} else if hours > 0 {
|
||||
format!("{}h {}m", hours, minutes)
|
||||
} else {
|
||||
format!("{}m", minutes)
|
||||
}
|
||||
} else {
|
||||
timestamp.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn format_usage_bar(percent: f32, width: usize) -> String {
|
||||
let filled = ((percent / 100.0) * width as f32).round() as usize;
|
||||
let filled = filled.min(width);
|
||||
let empty = width.saturating_sub(filled);
|
||||
let bar: String = "█".repeat(filled) + &"░".repeat(empty);
|
||||
format!("{} {:.0}%", bar, percent)
|
||||
}
|
||||
|
||||
fn provider_detail_lines(report: &jcode_usage_types::ProviderUsage) -> Vec<String> {
|
||||
let mut lines = Vec::new();
|
||||
lines.push("## Status".to_string());
|
||||
if let Some(error) = &report.error {
|
||||
lines.push(format!("• Error: {}", error));
|
||||
lines.push("".to_string());
|
||||
lines.push("## Next steps".to_string());
|
||||
lines.push(
|
||||
"• Re-run `/usage` to retry after credentials or network issues are fixed.".to_string(),
|
||||
);
|
||||
if report.provider_name.to_lowercase().contains("openai") {
|
||||
lines.push("• Use `/login openai` if the token needs refreshing.".to_string());
|
||||
} else if report.provider_name.to_lowercase().contains("anthropic")
|
||||
|| report.provider_name.to_lowercase().contains("claude")
|
||||
{
|
||||
lines.push("• Use `/login claude` if the token needs refreshing.".to_string());
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
lines.push(format!("• {}", provider_status(report).label()));
|
||||
if report.hard_limit_reached {
|
||||
lines.push("• Hard limit reached.".to_string());
|
||||
}
|
||||
|
||||
if !report.limits.is_empty() {
|
||||
lines.push("".to_string());
|
||||
lines.push("## Limits".to_string());
|
||||
for limit in &report.limits {
|
||||
let reset = limit
|
||||
.resets_at
|
||||
.as_deref()
|
||||
.map(format_reset_time)
|
||||
.map(|value| format!(" · resets in {}", value))
|
||||
.unwrap_or_default();
|
||||
lines.push(format!(
|
||||
"• {} {}{}",
|
||||
limit.name,
|
||||
format_usage_bar(limit.usage_percent, 18),
|
||||
reset
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if !report.extra_info.is_empty() {
|
||||
lines.push("".to_string());
|
||||
lines.push("## Details".to_string());
|
||||
for (key, value) in &report.extra_info {
|
||||
lines.push(format!("• {}: {}", key, value));
|
||||
}
|
||||
}
|
||||
|
||||
if report.limits.is_empty() && report.extra_info.is_empty() {
|
||||
lines.push("• No usage data available from this provider.".to_string());
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
fn truncate_with_ellipsis(input: &str, width: usize) -> String {
|
||||
if width == 0 {
|
||||
return String::new();
|
||||
}
|
||||
let chars: Vec<char> = input.chars().collect();
|
||||
if chars.len() <= width {
|
||||
return input.to_string();
|
||||
}
|
||||
if width <= 3 {
|
||||
return ".".repeat(width);
|
||||
}
|
||||
let mut out: String = chars.into_iter().take(width - 3).collect();
|
||||
out.push_str("...");
|
||||
out
|
||||
}
|
||||
|
||||
fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
|
||||
let popup = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
Constraint::Percentage(percent_y),
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
])
|
||||
.split(area);
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
Constraint::Percentage(percent_x),
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
])
|
||||
.split(popup[1])[1]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn status_labels_match_display_copy() {
|
||||
assert_eq!(UsageOverlayStatus::Good.label_for_display(), "healthy");
|
||||
assert_eq!(UsageOverlayStatus::Critical.icon(), "◆");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item_filter_searches_details_and_status() {
|
||||
let item = UsageOverlayItem::new(
|
||||
"claude",
|
||||
"Claude usage",
|
||||
"85% used",
|
||||
UsageOverlayStatus::Warning,
|
||||
vec!["resets tomorrow".to_string()],
|
||||
);
|
||||
assert!(item_matches_filter(&item, "watch tomorrow"));
|
||||
assert!(item_matches_filter(&item, "claude 85"));
|
||||
assert!(!item_matches_filter(&item, "openai"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_reports_build_searchable_overlay_items() {
|
||||
let overlay = UsageOverlay::from_provider_reports(
|
||||
&[jcode_usage_types::ProviderUsage {
|
||||
provider_name: "Claude".to_string(),
|
||||
limits: vec![jcode_usage_types::UsageLimit {
|
||||
name: "5h".to_string(),
|
||||
usage_percent: 92.0,
|
||||
resets_at: Some("2020-01-01T00:00:00Z".to_string()),
|
||||
}],
|
||||
extra_info: vec![("plan".to_string(), "max".to_string())],
|
||||
hard_limit_reached: false,
|
||||
error: None,
|
||||
last_used_unix_secs: None,
|
||||
}],
|
||||
false,
|
||||
1,
|
||||
1,
|
||||
false,
|
||||
);
|
||||
|
||||
assert_eq!(overlay.selected_item_title(), Some("Claude"));
|
||||
let details = overlay.selected_item_detail_text();
|
||||
assert!(details.contains("## Limits"));
|
||||
assert!(details.contains("5h"));
|
||||
assert!(details.contains("now"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user