Skip to content

Commit c194ec9

Browse files
crafts69guyclaude
andcommitted
Name the day a Usage reset lands on, and space the cards apart
A weekday is unambiguous inside a seven-day window but it is not legible: mapping Thu onto a calendar six days out was arithmetic the reader had to do, and the weekly bucket is exactly the one that lands far enough away to need it. The status line now reads `resets 08:53 Wed 19 Aug` and the window's own bar row `1d 12h · 19 Aug`, both only when the rollover is not today — so the presence of a date is itself the signal that the wait crosses a day. The countdown keeps its place beside it, being the only "how long do I wait" figure a window that missed the donut gets. `format_clock` and `format_reset` keep their contracts; the dated forms are wrappers over them, and `local_day` is the one definition of the calendar-day comparison `format_renewal` had inline. The cards also get a gutter. The pane paints no background and draws no divider, so whitespace is all that separates two providers, and butted together they read as one wide table of rows. It yields back to a single column on a pane too narrow to seat the cards' own rows. `Fill` replaces `Percentage(100 / n)`, which left the remainder dark on the right. Co-Authored-By: Claude Opus 5 <[email protected]>
1 parent 362657c commit c194ec9

3 files changed

Lines changed: 189 additions & 11 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,26 @@ project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
2020
created. A date already in the past reads as `unknown` too: the Codex claim only refreshes while
2121
Codex runs, and a stale date under a heading that says *renews* reads as one that is coming.
2222

23+
### Changed
24+
25+
- **A reset that is not today now names the day it falls on.** The status line reads
26+
`resets 08:53 Wed 19 Aug` and the window's own bar row reads `1d 12h · 19 Aug`. A weekday is
27+
unambiguous inside a seven-day window but it is not legible: mapping `Thu` onto a calendar six
28+
days out is arithmetic you had to do in your head, and the weekly bucket is exactly the one that
29+
lands far enough away to need it.
30+
31+
A window rolling over later today stays as short as it was — `resets 15:59 Thu`, `2h 40m` — so
32+
the presence of a date is itself the signal that the rollover is not today. The countdown keeps
33+
its place beside it: it is the only "how long do I wait" figure a window that missed the donut
34+
gets.
35+
36+
- **More air between the Usage cards.** The pane paints no background and draws no divider
37+
between providers, so whitespace is the only thing separating them — and with the columns
38+
butted together the two cards read as one wide table of rows rather than two independent
39+
answers. They now get a gutter, which yields back to one column on a pane too narrow to seat
40+
the cards' own rows. The columns also fill the pane exactly, instead of `100 / n` leaving the
41+
remainder dark on the right.
42+
2343
## [1.2.0] - 2026-08-19
2444

2545
### Added

‎docs/features.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ resets, and when the plan renews. Each agent gets a card:
2424
context window the last turn used, whether credits are on, whether a limit has actually been
2525
hit, and **when the subscription renews** — `renews 13 Sep · in 25d`.
2626
- A line saying **how old the reading is** and the wall clock the window rolls over at:
27-
`as of 12m ago · resets 08:53 Wed`.
27+
`as of 12m ago · resets 08:53 Wed 19 Aug`. The calendar date is there only when the rollover is
28+
not today — a window resetting this evening reads `resets 23:33 Mon` — so seeing a date is
29+
itself the signal that the wait crosses a day. The bar rows say it the other way round, as a
30+
countdown that picks up the same date when it needs one: `1d 12h · 19 Aug`.
2831

2932
That last line matters more than it looks. Codex reports whatever its last session wrote, so on a
3033
machine that has not run Codex since Tuesday the percentage *is* Tuesday's — and a stale number

‎src/usage.rs‎

Lines changed: 165 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -919,6 +919,21 @@ fn format_reset(now: u64, resets_at: u64) -> String {
919919
}
920920
}
921921

922+
/// `16h 4m` today, `1d 12h · 19 Aug` when the rollover lands on another day.
923+
///
924+
/// The countdown stays the primary answer — it is the only "how long do I wait"
925+
/// figure a window that missed the donut gets — and the date rides behind it for
926+
/// the windows far enough out that `1d 12h` hides which working day that is. A
927+
/// reset already past reads `now`, and a date beside that word would contradict
928+
/// it, so that case stays bare.
929+
fn format_reset_dated(now: u64, resets_at: u64, offset: i64) -> String {
930+
let relative = format_reset(now, resets_at);
931+
if resets_at <= now || local_day(resets_at, offset) == local_day(now, offset) {
932+
return relative;
933+
}
934+
format!("{relative} · {}", format_date(resets_at, offset))
935+
}
936+
922937
/// The local UTC offset in seconds, read once per refresh.
923938
///
924939
/// There is no time zone crate here and `std` has no local-time API, so the
@@ -968,6 +983,22 @@ fn format_clock(epoch: u64, offset: i64) -> String {
968983
format!("{:02}:{:02} {day}", seconds / 3_600, (seconds % 3_600) / 60)
969984
}
970985

986+
/// `14:55 Thu` when the window rolls over today, `14:55 Thu 21 Aug` when it
987+
/// does not.
988+
///
989+
/// The weekday alone is unambiguous inside a seven-day window but it is not
990+
/// legible: `Thu` six days out is a calendar lookup the reader has to do in
991+
/// their head, and the weekly window is the one that lands that far away.
992+
/// Today's reset stays short, so the date's presence is itself the signal that
993+
/// the rollover is not today.
994+
fn format_clock_dated(now: u64, epoch: u64, offset: i64) -> String {
995+
let clock = format_clock(epoch, offset);
996+
if clock.is_empty() || local_day(epoch, offset) == local_day(now, offset) {
997+
return clock;
998+
}
999+
format!("{clock} {}", format_date(epoch, offset))
1000+
}
1001+
9711002
/// What a card says when a date is not knowable. Written out rather than left
9721003
/// blank: a missing row reads as an oversight, `unknown` reads as an answer.
9731004
const UNKNOWN: &str = "unknown";
@@ -990,6 +1021,14 @@ fn format_date(epoch: u64, offset: i64) -> String {
9901021
format!("{day} {}", MONTHS[(month - 1) as usize])
9911022
}
9921023

1024+
/// The local calendar day an instant falls on, as a day number.
1025+
///
1026+
/// The unit every "is this today?" question on this card is asked in: elapsed
1027+
/// seconds answer a different question and get 23:59 → 00:01 wrong.
1028+
fn local_day(epoch: u64, offset: i64) -> i64 {
1029+
(epoch as i64 + offset).div_euclid(86_400)
1030+
}
1031+
9931032
/// Howard Hinnant's civil-from-days, the inverse of the days-from-civil in
9941033
/// [`parse_rfc3339_epoch`]. Returns `(year, month 1..=12, day 1..=31)`.
9951034
fn civil_from_days(days: i64) -> (i64, i64, i64) {
@@ -1026,8 +1065,7 @@ fn format_renewal(now: u64, renews_at: Option<u64>, offset: i64) -> String {
10261065
}
10271066
// Counted in local calendar days, not in elapsed seconds: "tomorrow" means
10281067
// the next day on the wall, and a renewal 20 hours away can be either.
1029-
let day_of = |epoch: u64| (epoch as i64 + offset).div_euclid(86_400);
1030-
let when = match day_of(at) - day_of(now) {
1068+
let when = match local_day(at, offset) - local_day(now, offset) {
10311069
d if d <= 0 => "today".to_string(),
10321070
1 => "tomorrow".to_string(),
10331071
d => format!("in {d}d"),
@@ -1204,6 +1242,32 @@ fn enabled_providers(cfg: &Config) -> Vec<Box<dyn Provider>> {
12041242
chosen
12051243
}
12061244

1245+
/// The blank columns between two provider cards.
1246+
///
1247+
/// Nothing draws a divider — the pane is transparent and a rule between the
1248+
/// cards would compete with the one each card already draws above its facts —
1249+
/// so the whitespace is the only thing separating them, and adjacent cards read
1250+
/// as one wide table of rows rather than two independent answers.
1251+
const CARD_GAP: u16 = 3;
1252+
1253+
/// The width a card needs before it can spare the full gutter: the longest row
1254+
/// it draws, `label + bar + percentage + a dated countdown`, plus the column
1255+
/// `draw_card` keeps clear on the right.
1256+
const CARD_COMFORTABLE: u16 = 44;
1257+
1258+
/// The gutter, but never at the expense of a card that is already cutting its
1259+
/// own rows. Two providers on the 96-column popup get the full gap; a pane
1260+
/// squeezed narrower spends its columns on content instead.
1261+
fn card_gap(width: u16, cards: usize) -> u16 {
1262+
let cards = cards.max(1) as u16;
1263+
let each = width.saturating_sub(CARD_GAP * (cards - 1)) / cards;
1264+
if each >= CARD_COMFORTABLE {
1265+
CARD_GAP
1266+
} else {
1267+
1
1268+
}
1269+
}
1270+
12071271
fn draw(f: &mut Frame, app: &App) {
12081272
let rows = Layout::vertical([Constraint::Min(1), Constraint::Length(1)]).split(f.area());
12091273
// Herdr frames and titles the popup pane already, so this draws no outer
@@ -1218,13 +1282,16 @@ fn draw(f: &mut Frame, app: &App) {
12181282
rows[0],
12191283
);
12201284
} else {
1221-
let share = 100 / app.slots.len() as u16;
1285+
// `Fill` rather than `Percentage(100 / n)`, which throws away the
1286+
// remainder — three providers took 99% of the pane and left a column
1287+
// dark on the right.
12221288
let columns = Layout::horizontal(
12231289
app.slots
12241290
.iter()
1225-
.map(|_| Constraint::Percentage(share))
1291+
.map(|_| Constraint::Fill(1))
12261292
.collect::<Vec<_>>(),
12271293
)
1294+
.spacing(card_gap(rows[0].width, app.slots.len()))
12281295
.split(rows[0]);
12291296
// Built once, here, because the same list has to size the cards and
12301297
// fill them: a count taken from one list and a render taken from
@@ -1351,7 +1418,7 @@ fn draw_card(f: &mut Frame, app: &App, slot: &Slot, facts: &[Fact], area: Rect,
13511418
Slot::Ready(report) => {
13521419
let mut note = freshness(app.now, report.measured_at);
13531420
if let Some(at) = hottest.and_then(|window| window.resets_at) {
1354-
let clock = format_clock(at, app.offset);
1421+
let clock = format_clock_dated(app.now, at, app.offset);
13551422
if !clock.is_empty() {
13561423
note = format!("{note} · resets {clock}");
13571424
}
@@ -1409,7 +1476,10 @@ fn window_line(
14091476
let color = window_color(theme, window, &app.cfg);
14101477
let filled = ((window.used_percent / 100.0) * BAR_W as f64).round() as usize;
14111478
let filled = filled.min(BAR_W);
1412-
let label_w = width.saturating_sub(BAR_W + 16).clamp(6, 8);
1479+
// The reservation is the bar plus " NNN%" plus the reset text, whose worst
1480+
// case is the dated form (`1d 12h · 19 Aug`) and its two leading spaces. On
1481+
// a card too narrow for all of it the label is what yields.
1482+
let label_w = width.saturating_sub(BAR_W + 23).clamp(6, 8);
14131483
let mut spans = vec![
14141484
Span::styled(
14151485
format!(" {:<label_w$}", clip(&window.label, label_w)),
@@ -1427,7 +1497,7 @@ fn window_line(
14271497
];
14281498
if let Some(at) = window.resets_at {
14291499
spans.push(Span::styled(
1430-
format!(" {}", format_reset(app.now, at)),
1500+
format!(" {}", format_reset_dated(app.now, at, app.offset)),
14311501
Style::default().fg(muted),
14321502
));
14331503
}
@@ -1965,6 +2035,47 @@ mod tests {
19652035
assert_eq!(format_clock(1_787_065_551, -18 * 3_600), "21:05 Mon");
19662036
}
19672037

2038+
#[test]
2039+
fn a_reset_on_another_day_carries_its_date() {
2040+
// 1787065551 is 2026-08-18T15:05:51Z, a Tuesday.
2041+
let now = 1_787_065_551;
2042+
// Later the same day: the weekday alone already says everything a date
2043+
// would, and the status line is one row on a half-pane-wide card.
2044+
assert_eq!(format_clock_dated(now, now + 3_600, 0), "16:05 Tue");
2045+
// Another day, so the date earns its columns.
2046+
assert_eq!(
2047+
format_clock_dated(now, now + 86_400 + 3_600, 0),
2048+
"16:05 Wed 19 Aug"
2049+
);
2050+
// Whether two instants share a day is only answerable in the viewer's
2051+
// zone: this pair is one day in UTC and two at +7h.
2052+
let evening = 1_787_065_551 + 6 * 3_600; // 21:05 Tue UTC
2053+
assert_eq!(format_clock_dated(now, evening, 0), "21:05 Tue");
2054+
assert_eq!(
2055+
format_clock_dated(now, evening, 7 * 3_600),
2056+
"04:05 Wed 19 Aug"
2057+
);
2058+
}
2059+
2060+
#[test]
2061+
fn a_countdown_names_the_day_when_it_is_not_today() {
2062+
// 1787000000 is 2026-08-17T20:53:20Z, a Monday.
2063+
let now = 1_787_000_000;
2064+
// 23:33 the same evening — the countdown is the whole answer.
2065+
assert_eq!(
2066+
format_reset_dated(now, now + 2 * 3_600 + 40 * 60, 0),
2067+
"2h 40m"
2068+
);
2069+
// A day and a half out, where "1d 12h" hides which working day it is.
2070+
assert_eq!(
2071+
format_reset_dated(now, now + 86_400 + 12 * 3_600, 0),
2072+
"1d 12h · 19 Aug"
2073+
);
2074+
// A reset already past reads `now`; a date beside that word would
2075+
// contradict it.
2076+
assert_eq!(format_reset_dated(now, now - 2 * 86_400, 0), "now");
2077+
}
2078+
19682079
#[test]
19692080
fn a_renewal_is_written_as_a_date_and_a_countdown() {
19702081
// 1787065551 is 2026-08-18T15:05:51Z.
@@ -2135,7 +2246,15 @@ mod tests {
21352246
screen.contains("12%"),
21362247
"the quieter window is still shown: {screen}"
21372248
);
2138-
assert!(screen.contains("1d 12h"), "{screen}");
2249+
// The countdown, and — because that rollover is not today — the day it
2250+
// lands on.
2251+
assert!(screen.contains("1d 12h · 19 Aug"), "{screen}");
2252+
// The other half of the rule: a window rolling over later the same
2253+
// evening stays bare, so the date's presence means "not today".
2254+
assert!(
2255+
screen.contains("2h 40m") && !screen.contains("2h 40m · "),
2256+
"a same-day rollover carries no date: {screen}"
2257+
);
21392258
// The facts that give the percentage its context.
21402259
assert!(screen.contains("19.1M · 97% cached"), "{screen}");
21412260
assert!(screen.contains("162k / 258k"), "{screen}");
@@ -2158,8 +2277,44 @@ mod tests {
21582277
let screen = render(&mut app, 60, 26);
21592278
assert!(screen.contains("as of 12m ago"), "{screen}");
21602279
assert!(
2161-
screen.contains("resets 08:53 Wed"),
2162-
"the wall clock too: {screen}"
2280+
screen.contains("resets 08:53 Wed 19 Aug"),
2281+
"the wall clock and its date too: {screen}"
2282+
);
2283+
}
2284+
2285+
#[test]
2286+
fn cards_are_separated_by_a_gutter_that_yields_before_the_content_does() {
2287+
// Two providers on the real 96-column popup: 46 columns each and the
2288+
// full gutter between them.
2289+
assert_eq!(card_gap(96, 2), CARD_GAP);
2290+
assert_eq!(card_gap(96, 1), CARD_GAP);
2291+
// Squeezed, the cards are already cutting their own rows, so the
2292+
// whitespace is the first thing to go rather than the last.
2293+
assert_eq!(card_gap(96, 3), 1);
2294+
assert_eq!(card_gap(60, 2), 1);
2295+
// Never a divide by zero, however the slots come out.
2296+
assert_eq!(card_gap(0, 0), 1);
2297+
}
2298+
2299+
#[test]
2300+
fn a_gutter_actually_separates_two_cards_on_screen() {
2301+
// The pane paints no background and draws no divider, so the blank
2302+
// columns are the only thing that keeps two cards from reading as one
2303+
// wide table of rows.
2304+
let mut app = app_with(vec![codex_slot(), codex_slot()]);
2305+
let screen = render(&mut app, 96, 26);
2306+
let bar = screen
2307+
.lines()
2308+
.find(|line| line.contains("weekly"))
2309+
.expect("a window row is drawn");
2310+
let first = bar.find("weekly").expect("the left card");
2311+
let second = bar.rfind("weekly").expect("the right card");
2312+
assert!(first < second, "two distinct cards: {bar:?}");
2313+
let between = &bar[first..second];
2314+
let blanks = between.len() - between.trim_end().len();
2315+
assert!(
2316+
blanks >= 4,
2317+
"at least the gutter plus each card's own margin: {bar:?}"
21632318
);
21642319
}
21652320

0 commit comments

Comments
 (0)