1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
//! `Pane::ClaudeUsage` renderer — the full Claude usage panel, ported
//! from the retired centered-modal overlay into a proper pane.
//! Shows the same content: session %, weekly %, per-model scoped
//! limits, resets, retry-after status, last error.
//!
//! Data source is `App::ai_usage_claude_accounts` (populated by
//! the per-account fetchers that also feed the statusline chip).
//! Task #944 (2026-08-16) — one titled section per configured
//! account, stacked vertically. Header advertises the `r refresh
//! · esc close` hints; the actual key handler lives in
//! `src/tui/handlers/pane.rs`.
//!
//! 2026-08-16 — this file was `ai_usage_view.rs` before the
//! `Pane::AiUsage` → `Pane::ClaudeUsage`/`Pane::CodexUsage` split;
//! the Codex-side renderer lives in `codex_usage_view.rs`.
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::app::App;
use crate::layout::PaneId;
use crate::ui::theme;
use crate::ui::usage_time::{format_long_time, format_short_time};
/// Bar width in cells. Was hard-coded to 60 in the overlay; here we
/// clamp to the available body width minus the `" N% used"` suffix
/// so a narrow pane still renders a bar rather than clipping off.
const MAX_BAR_WIDTH: u16 = 60;
const SUFFIX_CELLS: u16 = 10; // ` 100% used` = 10 cells
pub fn draw(frame: &mut Frame, app: &mut App, pid: PaneId, area: Rect, focused: bool) {
if area.width == 0 || area.height == 0 {
app.rects.claude_usage_pencils.clear();
return;
}
let t = theme::cur();
// Task #944 rename UX (2026-08-16) — clear + repopulate pencil
// hitrects for the mouse handler. Same pattern as the other
// per-render rect vecs (help_section_headers, request_vars_rows).
app.rects.claude_usage_pencils.clear();
// Header row inside the pane body — mirrors the "Esc to dismiss"
// affordance the overlay had, adjusted for the pane world where
// Esc focuses the tree and `r` refreshes.
let header_color = if focused { t.fg } else { t.comment };
let mut rows: Vec<Line<'static>> = vec![
Line::from(vec![
Span::styled(
" Claude usage ".to_string(),
Style::default()
.fg(header_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"· r refresh · esc close".to_string(),
Style::default()
.fg(t.comment)
.add_modifier(Modifier::ITALIC),
),
]),
Line::from(""),
];
let bar_w = area
.width
.saturating_sub(SUFFIX_CELLS + 2)
.min(MAX_BAR_WIDTH);
// Task #944 (2026-08-16) — one titled section per configured
// account. Sections are `── personal (active) ──` style; each
// shows Session + Weekly + scoped limits + retry-after + errors
// for that account, identical to the pre-multi-account render
// (which was implicitly a single-account view).
let accounts = app.ai_usage_claude_accounts.clone();
// #1049 (2026-08-20) — resolve active-account name fresh from
// config every render. Each `ClaudeAccountUsage.is_active` was
// stamped when its last fetch drained; a mid-session switch
// (config edit or `:ai.set_active_claude_account`) leaves those
// flags stale for up to the poll interval (~5 min). Reading
// config here means the `(active)` tail label always tracks the
// *current* selection.
let active_name_now: Option<String> = app
.config
.claude_accounts()
.into_iter()
.find(|a| a.active)
.map(|a| a.name);
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
// Task #944 rename UX (2026-08-16) — record per-section
// header info (row_index_within_rows, x_offset_of_pencil,
// account_name) so the mouse hitrect can be computed once we
// know the final scroll offset. Left-clean at empty state.
let mut pencil_meta: Vec<(usize, u16, String)> = Vec::new();
if accounts.is_empty() {
rows.push(Line::from(Span::styled(
"fetching… (link a token via `:ai.link_claude_token`)".to_string(),
Style::default().fg(t.comment),
)));
} else {
for (i, account) in accounts.iter().enumerate() {
let usage = &account.usage;
// #1103 f/u (2026-08-20) — active-account visual cues:
// (A) `(active)` tail becomes a green bold pill.
// (B) Left gutter accent bar (1 col `┃`) painted
// alongside every row in the account's block —
// green for active, dim for inactive. Together
// they answer "which one is active" at a glance
// and give clear vertical section boundaries.
let is_active = active_name_now.as_deref() == Some(account.name.as_str());
let gutter_style = if is_active {
Style::default().fg(t.green).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(t.bg_darker)
};
// #1103 f/u2 (2026-08-20) — heavier accent bar. `▌` (left
// half-block) renders as a solid pixel column vs the
// narrow line `┃` gave us; feels like a proper design-
// system gutter, not a text glyph.
let gutter_span = || Span::styled("▌ ", gutter_style);
// Helper: wrap a Line so every row in this account's
// block is prefixed with the gutter span. Consumes the
// input Line's spans and re-emits them after the gutter.
let with_gutter = |line: Line<'static>| -> Line<'static> {
let mut spans: Vec<Span<'static>> = vec![gutter_span()];
spans.extend(line.spans);
Line::from(spans)
};
// #1103 f/u2 (2026-08-20) — dropped the `── … ──` decoration
// (visual noise, not structure) and moved the `(active)`
// pill to the LEFT (right after the gutter) so the green
// gutter + green (active) pill sit adjacent — much
// punchier signal than a far-right label. Header shape:
// active: `▌ (active) <name> ✎ · <email> · <org>`
// inactive: `▌ <name> ✎ · <email> · <org>`
let pencil = "\u{F040}"; // nf-fa-pencil
// Build the identity middle-piece — `· email · org`,
// any/all optional. Rendered in comment tone so it reads
// as metadata not part of the header structure.
let mut identity = String::new();
if let Some(email) = account.email.as_deref() {
identity.push_str(" · ");
identity.push_str(email);
}
if let Some(org) = account.org_name.as_deref() {
identity.push_str(" · ");
identity.push_str(org);
}
let header_row_idx = rows.len();
let mut header_spans: Vec<Span<'static>> = vec![gutter_span()];
// Cumulative cell offset used to pin the pencil hitrect.
// Starts at 2 (gutter `▌ ` is 2 cells).
let mut cursor_cells: u16 = 2;
if is_active {
let pill = "(active) ";
header_spans.push(Span::styled(
pill.to_string(),
Style::default().fg(t.green).add_modifier(Modifier::BOLD),
));
cursor_cells += pill.chars().count() as u16;
}
let name = format!("{} ", account.name);
let name_cells = name.chars().count() as u16;
header_spans.push(Span::styled(
name,
Style::default().fg(t.fg).add_modifier(Modifier::BOLD),
));
cursor_cells += name_cells;
let prefix_cells = cursor_cells;
header_spans.push(Span::styled(
pencil.to_string(),
Style::default().fg(t.comment),
));
header_spans.push(Span::styled(identity, Style::default().fg(t.comment)));
rows.push(Line::from(header_spans));
pencil_meta.push((header_row_idx, prefix_cells, account.name.clone()));
rows.push(with_gutter(Line::from("")));
// Session
rows.push(with_gutter(Line::from(Span::styled(
"Current session".to_string(),
Style::default().fg(t.fg).add_modifier(Modifier::BOLD),
))));
rows.push(with_gutter(bar_row(usage.percent, bar_w, &t)));
rows.push(with_gutter(reset_row(usage.resets_at, &t)));
rows.push(with_gutter(Line::from("")));
// Weekly (all models)
rows.push(with_gutter(Line::from(Span::styled(
"Current week (all models)".to_string(),
Style::default().fg(t.fg).add_modifier(Modifier::BOLD),
))));
rows.push(with_gutter(bar_row(usage.weekly_percent, bar_w, &t)));
rows.push(with_gutter(reset_row_weekly(usage.weekly_resets_at, &t)));
rows.push(with_gutter(Line::from("")));
// Per-model scoped limits (e.g. Fable)
for scoped in &usage.scoped_limits {
rows.push(with_gutter(Line::from(Span::styled(
format!("Current week ({})", scoped.model_display_name),
Style::default().fg(t.fg).add_modifier(Modifier::BOLD),
))));
rows.push(with_gutter(bar_row(scoped.percent, bar_w, &t)));
if scoped.resets_at > 0 {
rows.push(with_gutter(reset_row_weekly(scoped.resets_at, &t)));
}
rows.push(with_gutter(Line::from("")));
}
// Retry-after — surfaced when Anthropic told THIS account
// to back off.
if usage.retry_after_at > now {
let remaining = usage.retry_after_at - now;
rows.push(with_gutter(Line::from(Span::styled(
format!(" Anthropic asked us to retry in {}s (429)", remaining),
Style::default().fg(t.yellow),
))));
rows.push(with_gutter(Line::from("")));
}
// Per-account empty state / stale-data hint
if usage.percent == 0 && usage.weekly_percent == 0 && usage.scoped_limits.is_empty() {
rows.push(with_gutter(Line::from(Span::styled(
match usage.last_error {
Some(ref e) => format!("no data yet · last error: {e}"),
None => "fetching…".to_string(),
},
Style::default().fg(t.comment),
))));
} else if let Some(ref e) = usage.last_error {
rows.push(with_gutter(Line::from(Span::styled(
format!(" last fetch error: {e}"),
Style::default().fg(t.red),
))));
}
// Blank separator between accounts (skip after the last).
// Bare Line (no gutter) so the gap reads cleanly between
// one account's gutter and the next.
if i + 1 < accounts.len() {
rows.push(Line::from(""));
}
}
}
// Footer hint
rows.push(Line::from(""));
rows.push(Line::from(Span::styled(
" `:ai.refresh_usage` to force fetch · `:ai.show_last_response` for raw JSON ".to_string(),
Style::default()
.fg(t.comment)
.add_modifier(Modifier::ITALIC),
)));
// Apply the pane's scroll offset. Clamp so `j` past the end
// doesn't blank the pane.
let total = rows.len();
let visible = area.height as usize;
let max_scroll = total.saturating_sub(visible.max(1));
let scroll = if let Some(crate::pane::Pane::ClaudeUsage(p)) = app.panes.get_mut(pid) {
p.scroll = p.scroll.min(max_scroll);
p.scroll
} else {
0
};
// Populate pencil hitrects — one per section header that's on
// screen after the scroll offset is applied. The pencil is a
// 2-cell-wide clickable target (glyph + a 1-cell hover slop
// so a slightly-off click still lands).
for (row_idx, x_off, name) in pencil_meta {
if row_idx < scroll {
continue;
}
let visible_y = (row_idx - scroll) as u16;
if visible_y >= area.height {
continue;
}
// Clip against the pane's right edge — a narrow pane can
// scroll the pencil off-screen; skip the rect entirely
// rather than reporting an out-of-bounds hitrect.
if x_off >= area.width {
continue;
}
let pencil_x = area.x + x_off;
let pencil_w = 2u16.min(area.width.saturating_sub(x_off));
app.rects.claude_usage_pencils.push((
Rect {
x: pencil_x,
y: area.y + visible_y,
width: pencil_w,
height: 1,
},
name,
));
}
let visible_rows: Vec<Line<'static>> = rows.into_iter().skip(scroll).collect();
frame.render_widget(
Paragraph::new(visible_rows).style(Style::default().fg(t.fg).bg(t.bg)),
area,
);
}
/// Progress bar row — full-width filled/empty frame + `N% used`
/// suffix. Color: green <60%, yellow 60-85%, red 85%+. The empty
/// portion uses a solid mid-tone background so the FRAME is always
/// visible even at 0% (overlay-era report 2026-08-05: 0% Fable
/// rendered as "nothing there").
fn bar_row(percent: u16, bar_w: u16, t: &crate::ui::theme::Theme) -> Line<'static> {
let clamped = percent.min(100);
let filled_w = ((bar_w as u32) * (clamped as u32) / 100) as u16;
let empty_w = bar_w.saturating_sub(filled_w);
let color = if percent >= 85 {
t.red
} else if percent >= 60 {
t.yellow
} else {
t.purple
};
let filled: String = " ".repeat(filled_w as usize);
let empty: String = " ".repeat(empty_w as usize);
Line::from(vec![
Span::styled(filled, Style::default().bg(color)),
Span::styled(empty, Style::default().bg(t.bg2)),
Span::styled(
format!(" {}% used", percent),
Style::default().fg(t.fg).add_modifier(Modifier::BOLD),
),
])
}
fn reset_row(resets_at: u64, t: &crate::ui::theme::Theme) -> Line<'static> {
if resets_at == 0 {
return Line::from(Span::styled(
" (reset time not available)".to_string(),
Style::default().fg(t.comment),
));
}
let short = format_short_time(resets_at);
Line::from(Span::styled(
format!(" Resets {short}"),
Style::default().fg(t.comment),
))
}
fn reset_row_weekly(resets_at: u64, t: &crate::ui::theme::Theme) -> Line<'static> {
if resets_at == 0 {
return Line::from(Span::styled(
" (reset time not available)".to_string(),
Style::default().fg(t.comment),
));
}
let full = format_long_time(resets_at);
Line::from(Span::styled(
format!(" Resets {full}"),
Style::default().fg(t.comment),
))
}