mirror of
https://github.com/openai/codex.git
synced 2026-11-12 21:20:34 +00:00
553 lines
17 KiB
Rust
553 lines
17 KiB
Rust
use super::ChatWidget;
|
|
use crate::app_event::AppEvent;
|
|
use crate::color::blend;
|
|
use crate::color::is_light;
|
|
use crate::history_cell::CompositeHistoryCell;
|
|
use crate::history_cell::HistoryCell;
|
|
use crate::history_cell::PlainHistoryCell;
|
|
use crate::history_cell::plain_lines;
|
|
use crate::history_cell::with_border_with_inner_width;
|
|
use crate::style::accent_style;
|
|
use crate::terminal_palette::best_color;
|
|
use crate::terminal_palette::default_bg;
|
|
use crate::wrapping::RtOptions;
|
|
use crate::wrapping::word_wrap_lines;
|
|
use chrono::DateTime;
|
|
use chrono::Utc;
|
|
use codex_app_server_protocol::UsageContributorKind;
|
|
use codex_app_server_protocol::UsageEntry;
|
|
use codex_app_server_protocol::UsageRange;
|
|
use codex_app_server_protocol::UsageReadResponse;
|
|
use codex_app_server_protocol::UsageReport;
|
|
use ratatui::prelude::*;
|
|
use ratatui::style::Stylize;
|
|
use unicode_width::UnicodeWidthChar;
|
|
use unicode_width::UnicodeWidthStr;
|
|
|
|
const USAGE_CARD_MAX_INNER_WIDTH: usize = 72;
|
|
const USAGE_CARD_EXTRA_HORIZONTAL_PADDING: usize = 1;
|
|
const USAGE_BAR_WIDTH: usize = 20;
|
|
const USAGE_BAR_MIN_LABEL_WIDTH: usize = 8;
|
|
const USAGE_BAR_GLYPH: &str = "▄";
|
|
const USAGE_TITLE: &str = "Usage by token share";
|
|
const USAGE_SUBTITLE: &str = "Percent of consumed tokens in this selected range";
|
|
|
|
impl ChatWidget {
|
|
pub(crate) fn add_usage_output(&mut self) {
|
|
self.add_usage_output_for_range(UsageRange::Day);
|
|
}
|
|
|
|
pub(crate) fn add_usage_output_for_range(&mut self, range: UsageRange) {
|
|
self.request_usage(range);
|
|
}
|
|
|
|
fn request_usage(&mut self, range: UsageRange) {
|
|
let request_id = self.next_usage_request_id;
|
|
self.next_usage_request_id = self.next_usage_request_id.saturating_add(/*rhs*/ 1);
|
|
self.active_usage_request_id = Some(request_id);
|
|
self.app_event_tx
|
|
.send(AppEvent::FetchUsage { request_id, range });
|
|
}
|
|
|
|
pub(crate) fn on_usage_loaded(
|
|
&mut self,
|
|
request_id: u64,
|
|
result: Result<UsageReadResponse, String>,
|
|
) {
|
|
if self.active_usage_request_id != Some(request_id) {
|
|
return;
|
|
}
|
|
self.active_usage_request_id = None;
|
|
let cell = match result {
|
|
Ok(response) => new_usage_output(response.report),
|
|
Err(err) => new_usage_error_output(err),
|
|
};
|
|
self.add_to_history(cell);
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct UsageHistoryCell {
|
|
report: UsageReport,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct UsageErrorHistoryCell {
|
|
error: String,
|
|
}
|
|
|
|
fn new_usage_output(report: UsageReport) -> CompositeHistoryCell {
|
|
let command = PlainHistoryCell::new(vec![usage_command_label(report.range).magenta().into()]);
|
|
CompositeHistoryCell::new(vec![
|
|
Box::new(command),
|
|
Box::new(UsageHistoryCell { report }),
|
|
])
|
|
}
|
|
|
|
fn new_usage_error_output(error: String) -> CompositeHistoryCell {
|
|
let command = PlainHistoryCell::new(vec!["/usage".magenta().into()]);
|
|
CompositeHistoryCell::new(vec![
|
|
Box::new(command),
|
|
Box::new(UsageErrorHistoryCell { error }),
|
|
])
|
|
}
|
|
|
|
impl HistoryCell for UsageHistoryCell {
|
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
usage_report_lines(&self.report, width)
|
|
}
|
|
|
|
fn raw_lines(&self) -> Vec<Line<'static>> {
|
|
plain_lines(self.display_lines(u16::MAX))
|
|
}
|
|
}
|
|
|
|
impl HistoryCell for UsageErrorHistoryCell {
|
|
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
|
let Some(available_width) = usage_card_available_width(width) else {
|
|
return Vec::new();
|
|
};
|
|
let mut lines = vec![
|
|
Line::from(USAGE_TITLE.bold()),
|
|
Line::from(USAGE_SUBTITLE.dim()),
|
|
Line::default(),
|
|
];
|
|
push_wrapped_text(
|
|
&mut lines,
|
|
format!(" Failed to load usage: {}", self.error),
|
|
available_width,
|
|
);
|
|
let inner_width = lines
|
|
.iter()
|
|
.map(line_display_width)
|
|
.max()
|
|
.unwrap_or(0)
|
|
.min(available_width);
|
|
with_usage_border(lines, inner_width)
|
|
}
|
|
|
|
fn raw_lines(&self) -> Vec<Line<'static>> {
|
|
plain_lines(self.display_lines(u16::MAX))
|
|
}
|
|
}
|
|
|
|
fn usage_report_lines(report: &UsageReport, width: u16) -> Vec<Line<'static>> {
|
|
let Some(available_width) = usage_card_available_width(width) else {
|
|
return Vec::new();
|
|
};
|
|
let sections = [
|
|
("Skills", report.skills.as_slice()),
|
|
("Subagents", report.subagents.as_slice()),
|
|
("Agent tasks", report.agent_tasks.as_slice()),
|
|
("Apps", report.apps.as_slice()),
|
|
("MCP servers", report.mcp_servers.as_slice()),
|
|
("Plugins", report.plugins.as_slice()),
|
|
];
|
|
let label_column_width = usage_label_column_width(§ions);
|
|
let inner_width = usage_content_width(report, §ions, label_column_width, available_width);
|
|
let mut lines = usage_header_lines(report, inner_width);
|
|
if let Some(headline) = report.headline.as_ref() {
|
|
lines.push(Line::default());
|
|
push_wrapped_line(
|
|
&mut lines,
|
|
vec![
|
|
Span::from(format!(
|
|
"{}% of consumed tokens came from {} \"{}\"",
|
|
headline.entry.percent_of_usage,
|
|
contributor_kind_label(headline.entry.kind),
|
|
headline.entry.label
|
|
))
|
|
.italic()
|
|
.dim(),
|
|
],
|
|
inner_width,
|
|
);
|
|
if let Some(note) = headline.note.as_ref() {
|
|
push_wrapped_text(&mut lines, format!(" {note}"), inner_width);
|
|
}
|
|
}
|
|
|
|
if report.total_tokens == 0 {
|
|
lines.push(Line::default());
|
|
push_wrapped_text(
|
|
&mut lines,
|
|
" No tracked usage in this range yet.",
|
|
inner_width,
|
|
);
|
|
return with_usage_border(lines, inner_width);
|
|
}
|
|
|
|
if sections.iter().all(|(_, entries)| entries.is_empty()) {
|
|
lines.push(Line::default());
|
|
push_wrapped_text(
|
|
&mut lines,
|
|
" No attributed skills, subagents, agent tasks, apps, MCP servers, or plugins in this range.",
|
|
inner_width,
|
|
);
|
|
return with_usage_border(lines, inner_width);
|
|
}
|
|
|
|
for (label, entries) in sections {
|
|
push_section(&mut lines, label, entries, label_column_width, inner_width);
|
|
}
|
|
with_usage_border(lines, inner_width)
|
|
}
|
|
|
|
fn usage_card_available_width(width: u16) -> Option<usize> {
|
|
let frame_width = 4 + USAGE_CARD_EXTRA_HORIZONTAL_PADDING * 2;
|
|
(usize::from(width) >= frame_width).then(|| {
|
|
usize::from(width)
|
|
.saturating_sub(frame_width)
|
|
.min(USAGE_CARD_MAX_INNER_WIDTH)
|
|
})
|
|
}
|
|
|
|
fn with_usage_border(lines: Vec<Line<'static>>, inner_width: usize) -> Vec<Line<'static>> {
|
|
let lines = lines
|
|
.into_iter()
|
|
.map(|line| {
|
|
let mut spans = Vec::with_capacity(line.spans.len() + 1);
|
|
spans.push(Span::from(" ".repeat(USAGE_CARD_EXTRA_HORIZONTAL_PADDING)));
|
|
spans.extend(line.spans);
|
|
Line::from(spans)
|
|
})
|
|
.collect();
|
|
with_border_with_inner_width(lines, inner_width + USAGE_CARD_EXTRA_HORIZONTAL_PADDING * 2)
|
|
}
|
|
|
|
fn usage_label_column_width(sections: &[(&'static str, &[UsageEntry])]) -> usize {
|
|
sections
|
|
.iter()
|
|
.flat_map(|(_, entries)| entries.iter())
|
|
.map(|entry| UnicodeWidthStr::width(entry.label.as_str()))
|
|
.max()
|
|
.unwrap_or(0)
|
|
.saturating_add(/*rhs*/ 3)
|
|
}
|
|
|
|
fn usage_content_width(
|
|
report: &UsageReport,
|
|
sections: &[(&'static str, &[UsageEntry])],
|
|
label_column_width: usize,
|
|
available_width: usize,
|
|
) -> usize {
|
|
let mut width = 0usize;
|
|
for line in usage_header_lines(report, available_width) {
|
|
width = width.max(line_display_width(&line));
|
|
}
|
|
|
|
if let Some(headline) = report.headline.as_ref() {
|
|
width = width.max(
|
|
text_width(
|
|
format!(
|
|
"{}% of consumed tokens came from {} \"{}\"",
|
|
headline.entry.percent_of_usage,
|
|
contributor_kind_label(headline.entry.kind),
|
|
headline.entry.label
|
|
)
|
|
.as_str(),
|
|
)
|
|
.min(available_width),
|
|
);
|
|
}
|
|
|
|
if report.total_tokens == 0 {
|
|
return width.min(available_width);
|
|
}
|
|
|
|
if sections.iter().all(|(_, entries)| entries.is_empty()) {
|
|
return width.min(available_width);
|
|
}
|
|
|
|
for (section_label, entries) in sections {
|
|
if entries.is_empty() {
|
|
continue;
|
|
}
|
|
width = width.max(text_width(format!(" {section_label}").as_str()));
|
|
}
|
|
|
|
let prefix_width = text_width(" ├─ ");
|
|
let percent_width = text_width("100%");
|
|
let row_width_with_bar =
|
|
prefix_width + label_column_width + USAGE_BAR_WIDTH + 2 + percent_width;
|
|
let can_show_bar =
|
|
available_width >= row_width_with_bar && label_column_width >= USAGE_BAR_MIN_LABEL_WIDTH;
|
|
let row_width = if can_show_bar {
|
|
row_width_with_bar
|
|
} else {
|
|
sections
|
|
.iter()
|
|
.flat_map(|(_, entries)| entries.iter())
|
|
.map(|entry| prefix_width + text_width(entry.label.as_str()) + 1 + percent_width)
|
|
.max()
|
|
.unwrap_or(0)
|
|
};
|
|
width.max(row_width).min(available_width)
|
|
}
|
|
|
|
fn usage_header_lines(report: &UsageReport, inner_width: usize) -> Vec<Line<'static>> {
|
|
match report.range {
|
|
UsageRange::Day => vec![
|
|
Line::from(usage_title(report.range).bold()),
|
|
Line::from(USAGE_SUBTITLE.dim()),
|
|
vec![
|
|
"(".dim(),
|
|
Span::styled("/usage week", accent_style()),
|
|
" for weekly)".dim(),
|
|
]
|
|
.into(),
|
|
],
|
|
UsageRange::Week => {
|
|
let Some(period) = usage_period_label(report) else {
|
|
return vec![
|
|
Line::from(usage_title(report.range).bold()),
|
|
Line::from(USAGE_SUBTITLE.dim()),
|
|
];
|
|
};
|
|
let combined_title = format!("{}, {period}", usage_title(report.range));
|
|
if text_width(&combined_title) <= inner_width {
|
|
vec![
|
|
Line::from(combined_title.bold()),
|
|
Line::from(USAGE_SUBTITLE.dim()),
|
|
]
|
|
} else {
|
|
vec![
|
|
Line::from(usage_title(report.range).bold()),
|
|
Line::from(period),
|
|
Line::from(USAGE_SUBTITLE.dim()),
|
|
]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn usage_title(range: UsageRange) -> &'static str {
|
|
match range {
|
|
UsageRange::Day => "Daily usage by token share",
|
|
UsageRange::Week => "Weekly usage by token share",
|
|
}
|
|
}
|
|
|
|
fn usage_command_label(range: UsageRange) -> &'static str {
|
|
match range {
|
|
UsageRange::Day => "/usage",
|
|
UsageRange::Week => "/usage week",
|
|
}
|
|
}
|
|
|
|
fn usage_period_label(report: &UsageReport) -> Option<String> {
|
|
let range_start = report
|
|
.generated_at
|
|
.saturating_sub(usage_range_seconds(report.range));
|
|
let start = format_usage_date(range_start)?;
|
|
let end = format_usage_date(report.generated_at)?;
|
|
let label = format!("{start} to {end}");
|
|
Some(label)
|
|
}
|
|
|
|
fn usage_range_seconds(range: UsageRange) -> i64 {
|
|
match range {
|
|
UsageRange::Day => 24 * 60 * 60,
|
|
UsageRange::Week => 7 * 24 * 60 * 60,
|
|
}
|
|
}
|
|
|
|
fn format_usage_date(seconds: i64) -> Option<String> {
|
|
DateTime::<Utc>::from_timestamp(seconds, /*nsecs*/ 0)
|
|
.map(|timestamp| timestamp.format("%b %-d").to_string())
|
|
}
|
|
|
|
fn push_wrapped_text(lines: &mut Vec<Line<'static>>, text: impl Into<String>, inner_width: usize) {
|
|
push_wrapped_line(lines, Line::from(text.into()), inner_width);
|
|
}
|
|
|
|
fn push_wrapped_line(
|
|
lines: &mut Vec<Line<'static>>,
|
|
line: impl Into<Line<'static>>,
|
|
inner_width: usize,
|
|
) {
|
|
lines.extend(word_wrap_lines(
|
|
[line.into()],
|
|
RtOptions::new(inner_width.max(/*other*/ 1)).subsequent_indent(" ".into()),
|
|
));
|
|
}
|
|
|
|
fn push_section(
|
|
lines: &mut Vec<Line<'static>>,
|
|
label: &'static str,
|
|
entries: &[UsageEntry],
|
|
label_column_width: usize,
|
|
inner_width: usize,
|
|
) {
|
|
if entries.is_empty() {
|
|
return;
|
|
}
|
|
lines.push(Line::default());
|
|
lines.push(Line::from(Span::styled(
|
|
format!(" {label}"),
|
|
accent_style(),
|
|
)));
|
|
for (index, entry) in entries.iter().enumerate() {
|
|
let is_last = index + 1 == entries.len();
|
|
lines.push(usage_entry_line(
|
|
entry,
|
|
is_last,
|
|
label_column_width,
|
|
inner_width,
|
|
));
|
|
}
|
|
}
|
|
|
|
fn usage_entry_line(
|
|
entry: &UsageEntry,
|
|
is_last: bool,
|
|
label_column_width: usize,
|
|
inner_width: usize,
|
|
) -> Line<'static> {
|
|
let prefix = if is_last { " └─ " } else { " ├─ " };
|
|
let percent = format!("{:>3}%", entry.percent_of_usage);
|
|
let prefix_width = UnicodeWidthStr::width(prefix);
|
|
let percent_width = UnicodeWidthStr::width(percent.as_str());
|
|
let trailing_bar_width = USAGE_BAR_WIDTH + 2 + percent_width;
|
|
let include_bar = inner_width >= prefix_width + label_column_width + trailing_bar_width
|
|
&& label_column_width >= USAGE_BAR_MIN_LABEL_WIDTH;
|
|
let label_width = if include_bar {
|
|
label_column_width
|
|
} else {
|
|
inner_width.saturating_sub(prefix_width + percent_width + 1)
|
|
};
|
|
let label = truncate_to_width(&entry.label, label_width);
|
|
let used_width = if include_bar {
|
|
prefix_width + label_column_width + trailing_bar_width
|
|
} else {
|
|
prefix_width + UnicodeWidthStr::width(label.as_str()) + percent_width
|
|
};
|
|
let spacer_width = if include_bar {
|
|
0
|
|
} else {
|
|
inner_width.saturating_sub(used_width)
|
|
};
|
|
|
|
let mut spans = vec![
|
|
Span::from(prefix).dim(),
|
|
Span::from(label),
|
|
Span::from(" ".repeat(spacer_width)).dim(),
|
|
];
|
|
if include_bar {
|
|
let label_padding = label_column_width
|
|
.saturating_sub(UnicodeWidthStr::width(entry.label.as_str()).min(label_width));
|
|
spans.push(Span::from(" ".repeat(label_padding)).dim());
|
|
let (filled, empty) = usage_bar_segments(entry.percent_of_usage);
|
|
spans.push(usage_bar_filled_span(USAGE_BAR_GLYPH.repeat(filled)));
|
|
spans.push(usage_bar_empty_span(USAGE_BAR_GLYPH.repeat(empty)));
|
|
spans.push(Span::from(" ").dim());
|
|
}
|
|
spans.push(Span::from(percent));
|
|
Line::from(spans)
|
|
}
|
|
|
|
fn usage_bar_segments(percent: u8) -> (usize, usize) {
|
|
let filled = if percent == 0 {
|
|
0
|
|
} else {
|
|
((usize::from(percent) * USAGE_BAR_WIDTH).saturating_add(99)) / 100
|
|
}
|
|
.min(USAGE_BAR_WIDTH);
|
|
(filled, USAGE_BAR_WIDTH.saturating_sub(filled))
|
|
}
|
|
|
|
fn usage_bar_filled_span(content: String) -> Span<'static> {
|
|
Span::styled(content, usage_bar_filled_style())
|
|
}
|
|
|
|
fn usage_bar_empty_span(content: String) -> Span<'static> {
|
|
Span::styled(content, usage_bar_empty_style())
|
|
}
|
|
|
|
fn usage_bar_filled_style() -> Style {
|
|
usage_bar_filled_style_for(default_bg())
|
|
}
|
|
|
|
fn usage_bar_empty_style() -> Style {
|
|
usage_bar_empty_style_for(default_bg())
|
|
}
|
|
|
|
fn usage_bar_filled_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style {
|
|
let Some(bg) = terminal_bg else {
|
|
return Style::default().fg(Color::Cyan).bold();
|
|
};
|
|
if is_light(bg) {
|
|
Style::default()
|
|
.fg(usage_best_color(/*target*/ (0, 110, 125), Color::Cyan))
|
|
.bold()
|
|
} else {
|
|
Style::default()
|
|
.fg(usage_best_color(
|
|
/*target*/ (170, 210, 218),
|
|
Color::Cyan,
|
|
))
|
|
.bold()
|
|
}
|
|
}
|
|
|
|
fn usage_bar_empty_style_for(terminal_bg: Option<(u8, u8, u8)>) -> Style {
|
|
let Some(bg) = terminal_bg else {
|
|
return Style::default().fg(Color::DarkGray);
|
|
};
|
|
if is_light(bg) {
|
|
Style::default().fg(usage_best_color(
|
|
/*target*/ blend(/*fg*/ (0, 0, 0), bg, /*alpha*/ 0.18),
|
|
Color::Gray,
|
|
))
|
|
} else {
|
|
Style::default().fg(usage_best_color(
|
|
/*target*/ blend(/*fg*/ (255, 255, 255), bg, /*alpha*/ 0.22),
|
|
Color::DarkGray,
|
|
))
|
|
}
|
|
}
|
|
|
|
fn usage_best_color(target: (u8, u8, u8), fallback: Color) -> Color {
|
|
let color = best_color(target);
|
|
if color == Color::default() {
|
|
fallback
|
|
} else {
|
|
color
|
|
}
|
|
}
|
|
|
|
fn truncate_to_width(value: &str, width: usize) -> String {
|
|
let mut out = String::new();
|
|
let mut used = 0usize;
|
|
for ch in value.chars() {
|
|
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
|
|
if used + ch_width > width {
|
|
break;
|
|
}
|
|
out.push(ch);
|
|
used += ch_width;
|
|
}
|
|
out
|
|
}
|
|
|
|
fn line_display_width(line: &Line<'static>) -> usize {
|
|
line.iter()
|
|
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
|
|
.sum()
|
|
}
|
|
|
|
fn text_width(value: &str) -> usize {
|
|
UnicodeWidthStr::width(value)
|
|
}
|
|
|
|
fn contributor_kind_label(kind: UsageContributorKind) -> &'static str {
|
|
match kind {
|
|
UsageContributorKind::Skill => "skill",
|
|
UsageContributorKind::Subagent => "subagent",
|
|
UsageContributorKind::AgentTask => "agent task",
|
|
UsageContributorKind::App => "app",
|
|
UsageContributorKind::McpServer => "MCP server",
|
|
UsageContributorKind::Plugin => "plugin",
|
|
}
|
|
}
|