mirror of
https://github.com/openai/codex.git
synced 2026-10-15 16:48:52 +00:00
Honor the system clock preference across more TUI timestamps (#47321)
## Why Several TUI views hard-code 24-hour timestamps even when the detected system preference is 12-hour time. ## What changed Use the existing `ClockFormat` preference for rate-limit and spend-control reset times, usage notices, backend banners, reset-credit expiration times, analytics timestamps, and expanded session details in the resume picker. Capture the preference when constructing views and preserve existing timezone handling, including analytics' UTC labels. ## Testing Add coverage for midnight, noon, and next-day reset labels in both clock formats, plus 12-hour analytics rendering with UTC labels. Update snapshots for status, banners, usage notices, reset-credit details, and expanded sessions, including narrow-width layouts. Pin test fixtures to explicit clock formats for deterministic output. GitOrigin-RevId: 73c36d603ddc4eb7800614650a14c6f2961f8b70
This commit is contained in:
@@ -58,6 +58,7 @@ use sections::SectionState;
|
||||
use sections::SectionStates;
|
||||
|
||||
pub(crate) struct AnalyticsView {
|
||||
clock_format: crate::clock_format::ClockFormat,
|
||||
model_names: std::collections::HashMap<String, String>,
|
||||
sections: SectionStates,
|
||||
chats: Load<chats::Chats>,
|
||||
@@ -98,6 +99,7 @@ pub(crate) struct AnalyticsView {
|
||||
impl AnalyticsView {
|
||||
pub(crate) fn new(keymap: ListKeymap) -> Self {
|
||||
let mut view = Self {
|
||||
clock_format: crate::clock_format::ClockFormat::system(),
|
||||
model_names: std::collections::HashMap::new(),
|
||||
sections: SectionStates(std::array::from_fn(|_| SectionState::default())),
|
||||
chats: Load::Unavailable,
|
||||
|
||||
@@ -336,9 +336,12 @@ impl AnalyticsView {
|
||||
.and_then(|value| chrono::DateTime::from_timestamp(value, /*nsecs*/ 0))
|
||||
{
|
||||
lines.push(
|
||||
format!("Updated {} UTC", updated.format("%b %-d %H:%M"))
|
||||
.set_style(secondary_style())
|
||||
.into(),
|
||||
format!(
|
||||
"Updated {} UTC",
|
||||
updated.format(self.clock_format.date_time_format())
|
||||
)
|
||||
.set_style(secondary_style())
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if side_by_side {
|
||||
|
||||
@@ -160,7 +160,7 @@ impl AnalyticsView {
|
||||
{
|
||||
controls.push(format!(
|
||||
"Report updated {} UTC",
|
||||
updated.format("%b %-d %H:%M")
|
||||
updated.format(self.clock_format.date_time_format())
|
||||
));
|
||||
}
|
||||
controls
|
||||
|
||||
@@ -277,7 +277,12 @@ impl AnalyticsView {
|
||||
.and_then(|report| report.updated_at)
|
||||
.and_then(|value| chrono::DateTime::from_timestamp(value, /*nsecs*/ 0));
|
||||
let metadata = updated
|
||||
.map(|updated| format!("Updated · {} UTC", updated.format("%b %-d %H:%M")))
|
||||
.map(|updated| {
|
||||
format!(
|
||||
"Updated · {} UTC",
|
||||
updated.format(self.clock_format.date_time_format())
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
truncate(metadata.set_style(secondary_style()).into(), width).render(
|
||||
Rect::new(area.x, area.bottom() - 1, area.width, /*height*/ 1),
|
||||
|
||||
@@ -109,8 +109,8 @@ impl AnalyticsView {
|
||||
let label = format!(
|
||||
"{} {} – {}{}",
|
||||
if selected { "›" } else { " " },
|
||||
period.start.format("%b %-d %H:%M"),
|
||||
period.end.format("%b %-d %H:%M"),
|
||||
period.start.format(self.clock_format.date_time_format()),
|
||||
period.end.format(self.clock_format.date_time_format()),
|
||||
if period.start <= report.as_of && period.end > report.as_of {
|
||||
" *"
|
||||
} else {
|
||||
@@ -227,16 +227,19 @@ impl AnalyticsView {
|
||||
lines.push(
|
||||
format!(
|
||||
"Usage as of {} UTC · * current at last update",
|
||||
as_of.format("%b %-d %H:%M")
|
||||
as_of.format(self.clock_format.date_time_format())
|
||||
)
|
||||
.dim()
|
||||
.into(),
|
||||
);
|
||||
if let Some(start) = coverage_start {
|
||||
lines.push(
|
||||
format!("Available since {} UTC", start.format("%b %-d %H:%M"))
|
||||
.dim()
|
||||
.into(),
|
||||
format!(
|
||||
"Available since {} UTC",
|
||||
start.format(self.clock_format.date_time_format())
|
||||
)
|
||||
.dim()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if *approximate {
|
||||
|
||||
@@ -343,3 +343,20 @@ fn invalid_history_returns_safe_errors() {
|
||||
assert!(!error.contains("private wire value"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plan_clocks_follow_preference_and_keep_utc() {
|
||||
let mut view = fixture::view(AccountKind::Consumer);
|
||||
view.clock_format = crate::clock_format::ClockFormat::TwelveHour;
|
||||
view.plan.enabled = true;
|
||||
view.section = Section::Plan;
|
||||
view.plan.report = Load::Ready(Report::parse(response()).unwrap().unwrap());
|
||||
let rendered = view
|
||||
.plan_lines(/*width*/ 80)
|
||||
.0
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
insta::assert_snapshot!("plan_clocks_twelve_hour", rendered);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,42 @@ fn report_controls_show_dates_and_update_time() {
|
||||
assert!(output.contains("Updated · Sep 2 16:00 UTC"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twelve_hour_metadata_keeps_utc_across_renderers() {
|
||||
let mut view = fixture::view(models::AccountKind::Business);
|
||||
view.clock_format = crate::clock_format::ClockFormat::TwelveHour;
|
||||
view.section = Section::Usage;
|
||||
let controls = screen(&mut view, /*width*/ 40, /*height*/ 16);
|
||||
view.zoomed = false;
|
||||
view.tasks = Load::Ready(tasks::Chats {
|
||||
rows: vec![tasks::Chat {
|
||||
title: "Clock fixture".into(),
|
||||
task: None,
|
||||
}],
|
||||
updated_at: Some("2026-09-02T16:00:00Z".parse().unwrap()),
|
||||
..tasks::Chats::default()
|
||||
});
|
||||
let mut output = controls
|
||||
.lines()
|
||||
.filter(|line| line.contains("Updated"))
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>();
|
||||
for lines in [
|
||||
view.history_lines(Section::Usage, /*width*/ 40, /*height*/ 16)
|
||||
.lines,
|
||||
view.help_lines(/*width*/ 40),
|
||||
view.task_lines(/*width*/ 80).0,
|
||||
] {
|
||||
output.extend(
|
||||
lines
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.filter(|line| line.contains("Updated") || line.contains("Report updated")),
|
||||
);
|
||||
}
|
||||
insta::assert_snapshot!(output.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tall_reports_use_available_height_and_wide_reports_put_details_beside_plot() {
|
||||
for (width, height) in [(80, 60), (180, 60)] {
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
---
|
||||
source: tui/src/analytics/plan_tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
› 5-hour limits
|
||||
|
||||
› Sep 2 10:00 AM – Sep 2 3:00 PM * 125%
|
||||
|
||||
Weekly limits
|
||||
|
||||
› Sep 1 12:00 AM – Sep 8 12:00 AM * Not available
|
||||
Some periods aren't available yet
|
||||
Usage as of Sep 2 12:00 PM UTC · * current at last update
|
||||
Available since Sep 1 12:00 AM UTC
|
||||
Amounts and period boundaries are approximate; recent activity may be delayed
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/analytics/recovery_tests.rs
|
||||
expression: "output.join(\"\\n\")"
|
||||
---
|
||||
" Updated · Sep 2 4:00 PM UTC "
|
||||
Updated Sep 2 4:00 PM UTC
|
||||
Report updated Sep 2 4:00 PM UTC
|
||||
Updated Sep 2 4:00 PM UTC · recent activity may be delayed
|
||||
@@ -379,7 +379,7 @@ impl AnalyticsView {
|
||||
coverage.push(
|
||||
format!(
|
||||
"Updated {} UTC · recent activity may be delayed",
|
||||
time.format("%b %-d %H:%M")
|
||||
time.format(self.clock_format.date_time_format())
|
||||
)
|
||||
.set_style(secondary_style())
|
||||
.into(),
|
||||
|
||||
@@ -221,6 +221,7 @@ pub(super) fn view(kind: super::models::AccountKind) -> super::AnalyticsView {
|
||||
/// Populate explicit report state after a test chooses its account, period, and grouping.
|
||||
pub(super) fn seed_reports(view: &mut super::AnalyticsView) {
|
||||
use super::data::Load;
|
||||
view.clock_format = crate::clock_format::ClockFormat::TwentyFourHour;
|
||||
if view.business() {
|
||||
view.chats = Load::Ready(chats());
|
||||
}
|
||||
|
||||
@@ -7,16 +7,21 @@ use crate::app_event::AppEvent;
|
||||
use crate::bottom_pane::ActionableBanner;
|
||||
use crate::bottom_pane::BannerDismissal;
|
||||
use crate::bottom_pane::SelectionItem;
|
||||
use crate::clock_format::ClockFormat;
|
||||
use chrono::DateTime;
|
||||
use chrono::Local;
|
||||
|
||||
impl BackendBanner {
|
||||
pub(crate) fn actionable_banner(&self) -> ActionableBanner {
|
||||
pub(crate) fn actionable_banner(&self, clock_format: ClockFormat) -> ActionableBanner {
|
||||
let reset_time = self
|
||||
.reset_at
|
||||
.and_then(|timestamp| DateTime::from_timestamp(timestamp, /*nsecs*/ 0))
|
||||
.map(|time| {
|
||||
crate::status::format_reset_timestamp(time.with_timezone(&Local), Local::now())
|
||||
crate::status::format_reset_timestamp(
|
||||
time.with_timezone(&Local),
|
||||
Local::now(),
|
||||
clock_format,
|
||||
)
|
||||
});
|
||||
let copy = |text: &str| {
|
||||
let text: String = text
|
||||
|
||||
@@ -15,7 +15,7 @@ fn parse_rejects_more_actions_than_the_inline_banner_can_display() {
|
||||
let banner = BackendBanner::parse(&raw).expect("eight actions fit");
|
||||
assert_eq!(
|
||||
banner
|
||||
.actionable_banner()
|
||||
.actionable_banner(crate::clock_format::ClockFormat::TwentyFourHour)
|
||||
.actions
|
||||
.into_iter()
|
||||
.map(|action| action.name)
|
||||
|
||||
@@ -2564,7 +2564,9 @@ mod tests {
|
||||
.expect("valid optional banner");
|
||||
let (tx, mut rx) = unbounded_channel();
|
||||
let mut pane = test_pane(AppEventSender::new(tx));
|
||||
pane.set_inline_banner(Some(banner.actionable_banner()));
|
||||
pane.set_inline_banner(Some(
|
||||
banner.actionable_banner(crate::clock_format::ClockFormat::TwentyFourHour),
|
||||
));
|
||||
let width = 44;
|
||||
let area = Rect::new(
|
||||
/*x*/ 0,
|
||||
|
||||
@@ -629,6 +629,7 @@ pub(crate) struct ChatWidget {
|
||||
codex_rate_limit_reached_type: Option<RateLimitReachedType>,
|
||||
codex_spend_control_reached: Option<bool>,
|
||||
rate_limit_warnings: RateLimitWarningState,
|
||||
clock_format: crate::clock_format::ClockFormat,
|
||||
usage_notice_state: usage_notice::UsageNoticeState,
|
||||
backend_banner_state: backend_banners::BackendBannerState,
|
||||
automatic_model_switch_state: backend_banners::AutomaticModelSwitchState,
|
||||
|
||||
@@ -416,7 +416,7 @@ impl ChatWidget {
|
||||
}
|
||||
let is_reserve = banner.is_some_and(|banner| banner.banner_type == LUNA_RESERVE_BANNER);
|
||||
let content = banner.map(|banner| {
|
||||
let mut content = banner.actionable_banner();
|
||||
let mut content = banner.actionable_banner(self.clock_format);
|
||||
if banner.banner_type == LUNA_RESERVE_BANNER
|
||||
&& self.current_model() != LUNA_RESERVE_MODEL
|
||||
{
|
||||
|
||||
@@ -161,6 +161,7 @@ impl ChatWidget {
|
||||
codex_rate_limit_reached_type: None,
|
||||
codex_spend_control_reached: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
clock_format: crate::clock_format::ClockFormat::system(),
|
||||
usage_notice_state: usage_notice::UsageNoticeState::default(),
|
||||
backend_banner_state: backend_banners::BackendBannerState::default(),
|
||||
automatic_model_switch_state: backend_banners::AutomaticModelSwitchState::default(),
|
||||
|
||||
@@ -378,8 +378,12 @@ impl ChatWidget {
|
||||
.limit_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| limit_id.clone());
|
||||
let display =
|
||||
rate_limit_snapshot_display_for_limit(&snapshot, limit_label, Local::now());
|
||||
let display = rate_limit_snapshot_display_for_limit(
|
||||
&snapshot,
|
||||
limit_label,
|
||||
Local::now(),
|
||||
self.clock_format,
|
||||
);
|
||||
self.rate_limit_snapshots_by_limit_id
|
||||
.insert(limit_id, display);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::clock_format::ClockFormat;
|
||||
use chrono::DateTime;
|
||||
use chrono::Local;
|
||||
use chrono::Utc;
|
||||
@@ -14,6 +15,7 @@ pub(super) struct ResetCreditOption {
|
||||
|
||||
pub(super) fn reset_credit_options(
|
||||
summary: &RateLimitResetCreditsSummary,
|
||||
clock_format: ClockFormat,
|
||||
) -> Vec<ResetCreditOption> {
|
||||
let available_count = summary.available_count.max(0);
|
||||
let detail_limit = usize::try_from(available_count).unwrap_or(usize::MAX);
|
||||
@@ -33,11 +35,11 @@ pub(super) fn reset_credit_options(
|
||||
let expiration = match credit.expires_at {
|
||||
Some(expires_at) => DateTime::<Utc>::from_timestamp(expires_at, 0)
|
||||
.map(|expires_at| {
|
||||
let expires_at = expires_at.with_timezone(&Local);
|
||||
format!(
|
||||
"Expires {}",
|
||||
expires_at
|
||||
.with_timezone(&Local)
|
||||
.format("%H:%M on %-d %b %Y")
|
||||
"Expires {} on {}",
|
||||
expires_at.format(clock_format.time_format()),
|
||||
expires_at.format("%-d %b %Y")
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "Expiration unavailable".to_string()),
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ expression: "render_bottom_popup(&chat, 44)"
|
||||
2 usage limit resets available.
|
||||
|
||||
|
||||
› 1. Full reset Expires 09:39 on 18 Jun
|
||||
› 1. Full reset Expires 9:39 AM on 18 Jun
|
||||
2026.
|
||||
2. Full reset Does not expire.
|
||||
3. Cancel
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
source: tui/src/chatwidget/usage_notice_tests.rs
|
||||
expression: "lines.join(\"\\n\")"
|
||||
---
|
||||
94 columns: ⚠ 5h limit: 8% left · resets at 13:00 · /status
|
||||
93 columns: ⚠ 5h limit: 8% left · /status
|
||||
98 columns: ⚠ 5h limit: 8% left · resets at 1:00 PM · /status
|
||||
97 columns: ⚠ 5h limit: 8% left · /status
|
||||
58 columns: ⚠ 5h limit: 8% left · /status
|
||||
57 columns: ⚠ 5h limit: 8% left
|
||||
38 columns: ⚠ 5h limit: 8% left
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
source: tui/src/chatwidget/usage_notice_tests.rs
|
||||
expression: "lines.join(\"\\n\")"
|
||||
---
|
||||
132 columns: ⚠ weekly limit: only 2% left · resets at 12:00 on 21 Sep · /status
|
||||
131 columns: ⚠ weekly limit: only 2% left · /status
|
||||
138 columns: ⚠ weekly limit: only 2% left · resets at 12:00 PM on 21 Sep · /status
|
||||
137 columns: ⚠ weekly limit: only 2% left · /status
|
||||
76 columns: ⚠ weekly limit: only 2% left · /status
|
||||
75 columns: ⚠ weekly limit: only 2% left
|
||||
56 columns: ⚠ weekly limit: only 2% left
|
||||
|
||||
@@ -29,10 +29,25 @@ fn banner_response(
|
||||
async fn backend_banner_presentation_and_cta_do_not_imply_recovery() {
|
||||
for presentation in [None, Some("inline"), Some("dismissible")] {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(Some("test-model-a")).await;
|
||||
let response = banner_response(
|
||||
let mut response = banner_response(
|
||||
presentation,
|
||||
json!([{"action":"notify_owner","label":"Notify owner"}]),
|
||||
);
|
||||
if presentation == Some("inline") {
|
||||
use chrono::Timelike;
|
||||
chat.clock_format = crate::clock_format::ClockFormat::TwelveHour;
|
||||
let banner = response.rate_limit_upsell.as_mut().unwrap();
|
||||
banner["description"] =
|
||||
json!("Your usage resets at {time}. Ask your owner for credits.");
|
||||
banner["reset_at"] = json!(
|
||||
chrono::Local::now()
|
||||
.with_hour(17)
|
||||
.unwrap()
|
||||
.with_minute(30)
|
||||
.unwrap()
|
||||
.timestamp()
|
||||
);
|
||||
}
|
||||
chat.update_backend_banner(&response);
|
||||
let initial = render_bottom_popup(&chat, /*width*/ 70);
|
||||
assert!(initial.contains("Selected model usage exhausted"));
|
||||
|
||||
@@ -250,6 +250,7 @@ pub(super) async fn make_chatwidget_manual_with_auth(
|
||||
session_telemetry,
|
||||
};
|
||||
let mut widget = ChatWidget::new_with_op_target(common, super::CodexOpTarget::Direct(op_tx));
|
||||
widget.clock_format = crate::clock_format::ClockFormat::TwentyFourHour;
|
||||
widget.windows_sandbox_host = crate::app::WindowsSandboxHost::Local;
|
||||
widget.windows_sandbox_config.requirements = Some(None);
|
||||
widget.transcript.active_cell = None;
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ source: tui/src/chatwidget/tests/backend_banners_tests.rs
|
||||
expression: "normalize_snapshot_paths(render_bottom_popup(&chat, 70))"
|
||||
---
|
||||
Selected model usage exhausted
|
||||
Ask your owner for credits or switch to another model.
|
||||
Your usage resets at 5:30 PM. Ask your owner for credits.
|
||||
|
||||
› 1. Notify owner
|
||||
|
||||
|
||||
@@ -98,10 +98,10 @@ fn reset_credit_options_use_generic_copy_when_backend_copy_is_missing() {
|
||||
credit.reset_type = RateLimitResetType::Unknown;
|
||||
|
||||
assert_eq!(
|
||||
reset_credit_options(&detailed_reset_credits(
|
||||
/*available_count*/ 1,
|
||||
vec![credit],
|
||||
)),
|
||||
reset_credit_options(
|
||||
&detailed_reset_credits(/*available_count*/ 1, vec![credit],),
|
||||
crate::clock_format::ClockFormat::TwentyFourHour
|
||||
),
|
||||
vec![ResetCreditOption {
|
||||
credit_id: Some("future-credit".to_string()),
|
||||
name: "Full reset".to_string(),
|
||||
@@ -416,6 +416,7 @@ async fn rate_limit_reset_popup_states_snapshot() {
|
||||
#[tokio::test]
|
||||
async fn rate_limit_reset_picker_wraps_expiry_details_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.clock_format = crate::clock_format::ClockFormat::TwelveHour;
|
||||
let request_id = chat.show_rate_limit_reset_loading_popup();
|
||||
let expiry = expiry_timestamp(/*day*/ 18, /*hour*/ 9, /*minute*/ 39);
|
||||
assert!(chat.finish_rate_limit_reset_credits_refresh(
|
||||
|
||||
@@ -165,7 +165,7 @@ impl ChatWidget {
|
||||
reset_credits: &RateLimitResetCreditsSummary,
|
||||
) -> SelectionViewParams {
|
||||
let confirmation_gate = Arc::new(AtomicBool::new(true));
|
||||
let mut items = reset_credit_options(reset_credits)
|
||||
let mut items = reset_credit_options(reset_credits, self.clock_format)
|
||||
.into_iter()
|
||||
.map(|option| {
|
||||
let confirmation_gate = confirmation_gate.clone();
|
||||
|
||||
@@ -6,6 +6,7 @@ use super::ChatWidget;
|
||||
use super::rate_limits::RateLimitSnapshotSource;
|
||||
use super::rate_limits::is_approximate_window;
|
||||
use super::rate_limits::limit_label_for_window;
|
||||
use crate::clock_format::ClockFormat;
|
||||
use crate::footer_hint::first_fitting_line;
|
||||
use chrono::DateTime;
|
||||
use chrono::Local;
|
||||
@@ -27,7 +28,12 @@ pub(super) struct UsageNoticeState {
|
||||
}
|
||||
|
||||
impl UsageNoticeState {
|
||||
fn line(&self, width: u16, now: DateTime<Local>) -> Option<Line<'static>> {
|
||||
fn line(
|
||||
&self,
|
||||
width: u16,
|
||||
now: DateTime<Local>,
|
||||
clock_format: ClockFormat,
|
||||
) -> Option<Line<'static>> {
|
||||
let (window, is_secondary) = self.current()?;
|
||||
let label = limit_label_for_window(window.window_duration_mins, is_secondary);
|
||||
// Percentages are rounded by the protocol; 100% alone does not prove a hard stop.
|
||||
@@ -49,7 +55,11 @@ impl UsageNoticeState {
|
||||
.map(|reset| {
|
||||
format!(
|
||||
" · resets at {}",
|
||||
crate::status::format_reset_timestamp(reset.with_timezone(&Local), now)
|
||||
crate::status::format_reset_timestamp(
|
||||
reset.with_timezone(&Local),
|
||||
now,
|
||||
clock_format
|
||||
)
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
@@ -161,7 +171,8 @@ impl ChatWidget {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
self.usage_notice_state.line(width, Local::now())
|
||||
self.usage_notice_state
|
||||
.line(width, Local::now(), self.clock_format)
|
||||
}
|
||||
|
||||
pub(crate) fn start_usage_notice_read(&mut self, request_id: u64) {
|
||||
|
||||
@@ -38,7 +38,7 @@ fn notice_details_fit_without_truncating_the_warning() {
|
||||
primary: Some(primary),
|
||||
..Default::default()
|
||||
},
|
||||
&[94, 93, 58, 57, 38, 37, 12, 11, 0][..],
|
||||
&[98, 97, 58, 57, 38, 37, 12, 11, 0][..],
|
||||
),
|
||||
(
|
||||
"weekly_usage_notice_widths",
|
||||
@@ -50,12 +50,12 @@ fn notice_details_fit_without_truncating_the_warning() {
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
&[132, 131, 76, 75, 56, 55, 16, 15][..],
|
||||
&[138, 137, 76, 75, 56, 55, 16, 15][..],
|
||||
),
|
||||
] {
|
||||
let mut lines = Vec::new();
|
||||
for &width in widths {
|
||||
let line = state.line(width, now);
|
||||
let line = state.line(width, now, ClockFormat::TwelveHour);
|
||||
assert!(
|
||||
line.as_ref()
|
||||
.is_none_or(|line| line.width() <= usize::from(width))
|
||||
@@ -89,7 +89,7 @@ fn notice_shows_only_valid_future_reset_times() {
|
||||
] {
|
||||
state.primary.as_mut().unwrap().resets_at = reset;
|
||||
assert_eq!(
|
||||
state.line(/*width*/ 160, now),
|
||||
state.line(/*width*/ 160, now, ClockFormat::TwentyFourHour),
|
||||
Some(
|
||||
Line::from(format!("⚠ 5h limit: 8% left{expected} · /status"))
|
||||
.style(crate::style::warning_notice_style().bold())
|
||||
@@ -114,7 +114,11 @@ fn notice_emphasis_tracks_utilization_without_claiming_a_hard_stop() {
|
||||
};
|
||||
let style = crate::style::warning_notice_style();
|
||||
assert_eq!(
|
||||
state.line(/*width*/ 80, notice_time()),
|
||||
state.line(
|
||||
/*width*/ 80,
|
||||
notice_time(),
|
||||
ClockFormat::TwentyFourHour
|
||||
),
|
||||
Some(
|
||||
Line::from(format!("⚠ 5h limit: {remaining} left · /status")).style(if bold {
|
||||
style.bold()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Detects the TUI host's clock preference once. Unknown preferences preserve 12-hour output;
|
||||
//! detection never changes the process locale or runs while rendering a history cell.
|
||||
//! detection never changes the process locale. Views capture the preference for consistent rendering.
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -15,6 +15,13 @@ impl ClockFormat {
|
||||
*FORMAT.get_or_init(|| detect().unwrap_or(Self::TwelveHour))
|
||||
}
|
||||
|
||||
pub(crate) fn date_time_format(self) -> &'static str {
|
||||
match self {
|
||||
Self::TwelveHour => "%b %-d %-I:%M %p",
|
||||
Self::TwentyFourHour => "%b %-d %H:%M",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn time_format(self) -> &'static str {
|
||||
match self {
|
||||
Self::TwelveHour => "%-I:%M %p",
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::sync::Arc;
|
||||
|
||||
use crate::app_server_session::AppServerSession;
|
||||
use crate::clipboard_paste::normalize_pasted_search_query;
|
||||
use crate::clock_format::ClockFormat;
|
||||
use crate::color::blend;
|
||||
use crate::color::is_light;
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
@@ -824,6 +825,7 @@ impl Drop for AltScreenGuard<'_> {
|
||||
}
|
||||
|
||||
struct PickerState {
|
||||
clock_format: ClockFormat,
|
||||
use_theme_colors: bool,
|
||||
// Resolve local filesystem membership once per cwd for each page-loading cycle.
|
||||
local_cwd_matches: HashMap<PathBuf, bool>,
|
||||
@@ -1023,6 +1025,7 @@ impl PickerState {
|
||||
action: SessionPickerAction,
|
||||
) -> Self {
|
||||
Self {
|
||||
clock_format: ClockFormat::system(),
|
||||
use_theme_colors: true,
|
||||
requester,
|
||||
relative_time_reference: None,
|
||||
@@ -3280,12 +3283,19 @@ fn render_expanded_session_details(
|
||||
|
||||
vec![
|
||||
expanded_detail_line("Session:", &session, width),
|
||||
expanded_time_detail_line("Created:", reference, row.created_at, width),
|
||||
expanded_time_detail_line(
|
||||
"Created:",
|
||||
reference,
|
||||
row.created_at,
|
||||
width,
|
||||
state.clock_format,
|
||||
),
|
||||
expanded_time_detail_line(
|
||||
"Updated:",
|
||||
reference,
|
||||
row.updated_at.or(row.created_at),
|
||||
width,
|
||||
state.clock_format,
|
||||
),
|
||||
expanded_detail_line("Directory:", &directory, width),
|
||||
expanded_detail_line("Branch:", &branch, width),
|
||||
@@ -3430,6 +3440,7 @@ fn expanded_time_detail_line(
|
||||
reference: DateTime<Utc>,
|
||||
ts: Option<DateTime<Utc>>,
|
||||
width: u16,
|
||||
clock_format: ClockFormat,
|
||||
) -> Line<'static> {
|
||||
let Some(ts) = ts else {
|
||||
return expanded_detail_line(label, "-", width);
|
||||
@@ -3437,7 +3448,7 @@ fn expanded_time_detail_line(
|
||||
let value = format!(
|
||||
"{} · {}",
|
||||
format_relative_time_long(reference, ts),
|
||||
format_timestamp(ts)
|
||||
format_timestamp(ts, clock_format)
|
||||
);
|
||||
expanded_detail_line(label, &value, width)
|
||||
}
|
||||
@@ -3492,8 +3503,12 @@ fn plural_time(value: i64, unit: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_timestamp(ts: DateTime<Utc>) -> String {
|
||||
ts.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||
fn format_timestamp(ts: DateTime<Utc>, clock_format: ClockFormat) -> String {
|
||||
ts.format(match clock_format {
|
||||
ClockFormat::TwelveHour => "%Y-%m-%d %-I:%M:%S %p",
|
||||
ClockFormat::TwentyFourHour => "%Y-%m-%d %H:%M:%S",
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn render_empty_state_line(state: &PickerState) -> Line<'static> {
|
||||
@@ -4133,6 +4148,7 @@ mod tests {
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
state.clock_format = ClockFormat::TwentyFourHour;
|
||||
state.relative_time_reference = parse_timestamp_str("2026-05-02T14:48:19Z");
|
||||
let row = Row {
|
||||
path: Some(PathBuf::from("/tmp/a.jsonl")),
|
||||
@@ -5811,6 +5827,7 @@ session_picker_view = "dense"
|
||||
/*filter_cwd*/ None,
|
||||
SessionPickerAction::Resume,
|
||||
);
|
||||
state.clock_format = ClockFormat::TwelveHour;
|
||||
state.all_rows = vec![row.clone()];
|
||||
state.filtered_rows = vec![row];
|
||||
state.relative_time_reference =
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@ expression: rendered
|
||||
---
|
||||
⌄ Investigate picker expansion
|
||||
│ Session: 019dabc1-0ef5-7431-b81c-03037f51f62c
|
||||
│ Created: 1 hour ago · 2026-04-28 16:30:00
|
||||
│ Updated: 15 minutes ago · 2026-04-28 17:45:00
|
||||
│ Created: 1 hour ago · 2026-04-28 4:30:00 PM
|
||||
│ Updated: 15 minutes ago · 2026-04-28 5:45:00 PM
|
||||
│ Directory: /tmp/codex
|
||||
│ Branch: fcoury/session-picker
|
||||
│
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::clock_format::ClockFormat;
|
||||
use crate::exec_command::relativize_to_home;
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::status::StatusAccountDisplay;
|
||||
@@ -180,8 +181,12 @@ pub(crate) fn format_directory_display(directory: &Path, max_width: Option<usize
|
||||
formatted
|
||||
}
|
||||
|
||||
pub(crate) fn format_reset_timestamp(dt: DateTime<Local>, captured_at: DateTime<Local>) -> String {
|
||||
let time = dt.format("%H:%M").to_string();
|
||||
pub(crate) fn format_reset_timestamp(
|
||||
dt: DateTime<Local>,
|
||||
captured_at: DateTime<Local>,
|
||||
clock_format: ClockFormat,
|
||||
) -> String {
|
||||
let time = dt.format(clock_format.time_format()).to_string();
|
||||
if dt.date_naive() == captured_at.date_naive() {
|
||||
time
|
||||
} else {
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
//! capture timestamp so stale detection and reset labels remain coherent for a given draw cycle.
|
||||
use crate::chatwidget::fallback_limit_label;
|
||||
use crate::chatwidget::limit_label_for_window;
|
||||
use crate::clock_format::ClockFormat;
|
||||
use crate::text_formatting::capitalize_first;
|
||||
|
||||
use super::helpers::format_reset_timestamp;
|
||||
@@ -76,12 +77,17 @@ pub(crate) struct RateLimitWindowDisplay {
|
||||
}
|
||||
|
||||
impl RateLimitWindowDisplay {
|
||||
fn from_window(window: &RateLimitWindow, captured_at: DateTime<Local>) -> Self {
|
||||
fn from_window(
|
||||
window: &RateLimitWindow,
|
||||
captured_at: DateTime<Local>,
|
||||
clock_format: ClockFormat,
|
||||
) -> Self {
|
||||
let resets_at_utc = window
|
||||
.resets_at
|
||||
.and_then(|seconds| DateTime::<Utc>::from_timestamp(seconds, 0))
|
||||
.map(|dt| dt.with_timezone(&Local));
|
||||
let resets_at = resets_at_utc.map(|dt| format_reset_timestamp(dt, captured_at));
|
||||
let resets_at =
|
||||
resets_at_utc.map(|dt| format_reset_timestamp(dt, captured_at, clock_format));
|
||||
|
||||
Self {
|
||||
used_percent: f64::from(window.used_percent),
|
||||
@@ -140,13 +146,19 @@ pub(crate) fn rate_limit_snapshot_display(
|
||||
snapshot: &RateLimitSnapshot,
|
||||
captured_at: DateTime<Local>,
|
||||
) -> RateLimitSnapshotDisplay {
|
||||
rate_limit_snapshot_display_for_limit(snapshot, "codex".to_string(), captured_at)
|
||||
rate_limit_snapshot_display_for_limit(
|
||||
snapshot,
|
||||
"codex".to_string(),
|
||||
captured_at,
|
||||
ClockFormat::TwentyFourHour,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn rate_limit_snapshot_display_for_limit(
|
||||
snapshot: &RateLimitSnapshot,
|
||||
limit_name: String,
|
||||
captured_at: DateTime<Local>,
|
||||
clock_format: ClockFormat,
|
||||
) -> RateLimitSnapshotDisplay {
|
||||
RateLimitSnapshotDisplay {
|
||||
normal_model_slug: snapshot.normal_model_slug.clone(),
|
||||
@@ -155,16 +167,15 @@ pub(crate) fn rate_limit_snapshot_display_for_limit(
|
||||
primary: snapshot
|
||||
.primary
|
||||
.as_ref()
|
||||
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
|
||||
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at, clock_format)),
|
||||
secondary: snapshot
|
||||
.secondary
|
||||
.as_ref()
|
||||
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
|
||||
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at, clock_format)),
|
||||
credits: snapshot.credits.as_ref().map(CreditsSnapshotDisplay::from),
|
||||
individual_limit: snapshot
|
||||
.individual_limit
|
||||
.as_ref()
|
||||
.and_then(|limit| SpendControlLimitSnapshotDisplay::from_limit(limit, captured_at)),
|
||||
individual_limit: snapshot.individual_limit.as_ref().and_then(|limit| {
|
||||
SpendControlLimitSnapshotDisplay::from_limit(limit, captured_at, clock_format)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,14 +193,16 @@ impl SpendControlLimitSnapshotDisplay {
|
||||
fn from_limit(
|
||||
value: &CoreSpendControlLimitSnapshot,
|
||||
captured_at: DateTime<Local>,
|
||||
clock_format: ClockFormat,
|
||||
) -> Option<Self> {
|
||||
Some(Self {
|
||||
captured_at,
|
||||
percent_remaining: f64::from(value.remaining_percent.clamp(0, 100)),
|
||||
used: format_credit_amount(&value.used)?,
|
||||
limit: format_credit_amount(&value.limit)?,
|
||||
resets_at: DateTime::<Utc>::from_timestamp(value.resets_at, 0)
|
||||
.map(|dt| format_reset_timestamp(dt.with_timezone(&Local), captured_at)),
|
||||
resets_at: DateTime::<Utc>::from_timestamp(value.resets_at, 0).map(|dt| {
|
||||
format_reset_timestamp(dt.with_timezone(&Local), captured_at, clock_format)
|
||||
}),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -17,6 +17,6 @@ expression: sanitized
|
||||
│ │
|
||||
│ Token usage: 1.9K total (1K input + 900 output) │
|
||||
│ Context window: 100% left (2.25K used / 272K) │
|
||||
│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 03:14) │
|
||||
│ Weekly limit: [███████████░░░░░░░░░] 55% left (resets 03:24) │
|
||||
│ 5h limit: [██████░░░░░░░░░░░░░░] 28% left (resets 3:14 AM) │
|
||||
│ Weekly limit: [███████████░░░░░░░░░] 55% left (resets 3:24 AM) │
|
||||
╰───────────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
@@ -7,6 +7,7 @@ use super::rate_limits::RateLimitWindowDisplay;
|
||||
use super::rate_limits::SpendControlLimitSnapshotDisplay;
|
||||
use super::rate_limits::StatusRateLimitData;
|
||||
use super::rate_limits::compose_rate_limit_data_many;
|
||||
use crate::clock_format::ClockFormat;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use crate::keymap::RuntimeKeymap;
|
||||
@@ -329,7 +330,12 @@ async fn status_snapshot_includes_reasoning_details() {
|
||||
plan_type: None,
|
||||
rate_limit_reached_type: None,
|
||||
};
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
let rate_display = super::rate_limits::rate_limit_snapshot_display_for_limit(
|
||||
&snapshot,
|
||||
"codex".to_string(),
|
||||
captured_at,
|
||||
ClockFormat::TwelveHour,
|
||||
);
|
||||
|
||||
let model_slug = get_model_offline_for_tests(config.model.as_deref());
|
||||
let token_info = token_info_for(&model_slug, &config, &usage);
|
||||
@@ -2273,3 +2279,36 @@ async fn status_permissions_include_executor_profile_root() {
|
||||
@r"Profile executor (workspace [\\server\share\foreign], Ask for approval)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_timestamps_follow_clock_preference() {
|
||||
let captured_at = Local
|
||||
.with_ymd_and_hms(
|
||||
/*year*/ 2026, /*month*/ 9, /*day*/ 21, /*hour*/ 0, /*min*/ 0,
|
||||
/*sec*/ 0,
|
||||
)
|
||||
.single()
|
||||
.unwrap();
|
||||
let labels = [ClockFormat::TwelveHour, ClockFormat::TwentyFourHour]
|
||||
.into_iter()
|
||||
.flat_map(|clock_format| {
|
||||
[0, 12, 23, 24].map(|hours| {
|
||||
super::helpers::format_reset_timestamp(
|
||||
captured_at + ChronoDuration::hours(hours),
|
||||
captured_at,
|
||||
clock_format,
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_snapshot!(labels.join("\n"), @"
|
||||
12:00 AM
|
||||
12:00 PM
|
||||
11:00 PM
|
||||
12:00 AM on 22 Sep
|
||||
00:00
|
||||
12:00
|
||||
23:00
|
||||
00:00 on 22 Sep
|
||||
");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user