mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
fix(tui): reduce startup and new-session latency (#17039)
## TL;DR
- Fetches account/rateLimits/read asynchronously so the TUI can continue
starting without waiting for the rate-limit response.
- Fixes the /status card so it no longer leaves a stale “refreshing
cached limits...” notice in terminal history.
## Problem
The TUI bootstrap path fetched account rate limits synchronously
(`account/rateLimits/read`) before the event loop started for
ChatGPT/OpenAI-authenticated startups. This added ~670 ms of blocking
latency in the measured hot-start case, even though rate-limit data is
not needed to render the initial UI or accept user input. The delay was
especially noticeable on hot starts where every other RPC
(`account/read`, `model/list`, `thread/start`) completed in under 70 ms
total.
Moving that fetch to the background also exposed a `/status` UI bug: the
status card is flattened into terminal scrollback when it is inserted. A
transient "refreshing limits in background..." line could not be cleared
later, because the async completion updated the retained `HistoryCell`,
not the already-written terminal history.
## Mental model
Before this change, `AppServerSession::bootstrap()` performed three
sequential RPCs: `account/read` → `model/list` →
`account/rateLimits/read`. The result of the third call was baked into
`AppServerBootstrap` and applied to the chat widget before the event
loop began.
After this change, `bootstrap()` only performs two RPCs (`account/read`
+ `model/list`), and rate-limit fetching is kicked off as an async
background task immediately after the first frame is scheduled. A new
enum, `RateLimitRefreshOrigin`, tags each fetch so the event handler
knows whether the result came from the startup prefetch or from a
user-initiated `/status` command; they have different completion
side-effects.
The `get_login_status()` helper (used outside the main app flow) was
also decoupled: it previously called the full `bootstrap()` just to
check auth mode, wasting model-list and rate-limit work. It now calls
the narrower `read_account()` directly.
For `/status`, this PR keeps the background refresh request but stops
printing transient refresh notices into status history when cached
limits are already available. If a refresh updates the cache, the next
`/status` command will render the new values.
## Non-goals
- This change does not alter the rate-limit data itself.
- This change does not introduce caching, retries, or staleness
management for rate limits.
- This change does not affect the `model/list` or `thread/start` RPCs;
they remain on the critical startup path.
## Tradeoffs
- **Stale-on-first-render**: The status bar will briefly show no
rate-limit info until the background fetch completes; observed
background fetches landed roughly in the 400-900 ms range after the UI
appeared. This is acceptable because the user cannot meaningfully act on
rate-limit data in the first fraction of a second.
- **Error silence on startup prefetch**: If the startup prefetch fails,
the error is logged but the UI is not notified (unlike `/status` refresh
failures, which go through the status-command completion path). This
avoids surfacing transient network errors as a startup blocker.
- **Static `/status` history**: `/status` output is terminal history,
not a live widget. The card now avoids progress-style language that
would appear stuck in scrollback; users can run `/status` again to see
newly cached values.
- **`account_auth_mode` field removed from `AppServerBootstrap`**: The
only consumer was `get_login_status()`, which no longer goes through
`bootstrap()`. The field was dead weight.
## Architecture
### New types
- `RateLimitRefreshOrigin` (in `app_event.rs`): A `Copy` enum
distinguishing `StartupPrefetch` from `StatusCommand { request_id }`.
Carried through `RefreshRateLimits` and `RateLimitsLoaded` events so the
handler applies the right completion behavior.
### Modified types
- `AppServerBootstrap`: Lost `account_auth_mode` and
`rate_limit_snapshots`; gained `requires_openai_auth: bool` (passed
through from the account response so the caller can decide whether to
fire the prefetch).
### Control flow
1. `bootstrap()` returns with `requires_openai_auth` and
`has_chatgpt_account`.
2. After scheduling the first frame, `App::run_inner` fires
`refresh_rate_limits(StartupPrefetch)` if both flags are true.
3. When `RateLimitsLoaded { StartupPrefetch, Ok(..) }` arrives,
snapshots are applied and a frame is scheduled to repaint the status
bar.
4. When `RateLimitsLoaded { StartupPrefetch, Err(..) }` arrives, the
error is logged and no UI update occurs.
5. `/status`-initiated refreshes continue to use `StatusCommand {
request_id }` and call `finish_status_rate_limit_refresh` on completion
(success or failure).
6. `/status` history cells with cached rate-limit rows no longer render
an additional "refreshing limits" notice; the async refresh updates the
cache for future status output.
### Extracted method
- `AppServerSession::read_account()`: Factored out of `bootstrap()` so
that `get_login_status()` can call it independently without triggering
model-list or rate-limit work.
## Observability
- The existing `tracing::warn!` for rate-limit fetch failures is
preserved for the startup path.
- No new metrics or spans are introduced. The startup-time improvement
is observable via the existing `ready` timestamp in TUI startup logs.
## Tests
- Existing tests in `status_command_tests.rs` are updated to match on
`RateLimitRefreshOrigin::StatusCommand { request_id }` instead of a bare
`request_id`.
- Focused `/status` tests now assert that status history avoids
transient refresh text, continues to request an async refresh, and uses
refreshed cached limits in future status output.
- No new tests are added for the startup prefetch path because it is a
fire-and-forget spawn with no observable side-effect other than the
widget state update, which is already covered by the
snapshot-application tests.
---------
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
Unverified
parent
80ebc80be5
commit
359e17a852
@@ -415,23 +415,11 @@ impl StatusHistoryCell {
|
||||
if rows_data.is_empty() {
|
||||
return vec![formatter.line(
|
||||
"Limits",
|
||||
vec![if state.refreshing_rate_limits {
|
||||
Span::from("refreshing cached limits...").dim()
|
||||
} else {
|
||||
Span::from("data not available yet").dim()
|
||||
}],
|
||||
vec![Span::from("not available for this account").dim()],
|
||||
)];
|
||||
}
|
||||
|
||||
let mut lines =
|
||||
self.rate_limit_row_lines(rows_data, available_inner_width, formatter);
|
||||
if state.refreshing_rate_limits {
|
||||
lines.push(formatter.line(
|
||||
"Notice",
|
||||
vec![Span::from("refreshing limits in background...").dim()],
|
||||
));
|
||||
}
|
||||
lines
|
||||
self.rate_limit_row_lines(rows_data, available_inner_width, formatter)
|
||||
}
|
||||
StatusRateLimitData::Stale(rows_data) => {
|
||||
let mut lines =
|
||||
@@ -439,7 +427,7 @@ impl StatusHistoryCell {
|
||||
lines.push(formatter.line(
|
||||
"Warning",
|
||||
vec![Span::from(if state.refreshing_rate_limits {
|
||||
"limits may be stale - refreshing in background..."
|
||||
"limits may be stale - run /status again shortly."
|
||||
} else {
|
||||
"limits may be stale - start new turn to refresh."
|
||||
})
|
||||
@@ -447,11 +435,17 @@ impl StatusHistoryCell {
|
||||
));
|
||||
lines
|
||||
}
|
||||
StatusRateLimitData::Unavailable => {
|
||||
vec![formatter.line(
|
||||
"Limits",
|
||||
vec![Span::from("not available for this account").dim()],
|
||||
)]
|
||||
}
|
||||
StatusRateLimitData::Missing => {
|
||||
vec![formatter.line(
|
||||
"Limits",
|
||||
vec![Span::from(if state.refreshing_rate_limits {
|
||||
"refreshing limits..."
|
||||
"refresh requested; run /status again shortly."
|
||||
} else {
|
||||
"data not available yet"
|
||||
})
|
||||
@@ -536,6 +530,7 @@ impl StatusHistoryCell {
|
||||
}
|
||||
push_label(labels, seen, "Warning");
|
||||
}
|
||||
StatusRateLimitData::Unavailable => push_label(labels, seen, "Limits"),
|
||||
StatusRateLimitData::Missing => push_label(labels, seen, "Limits"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ pub(crate) enum StatusRateLimitData {
|
||||
Available(Vec<StatusRateLimitRow>),
|
||||
/// Snapshot data exists but is older than the staleness threshold.
|
||||
Stale(Vec<StatusRateLimitRow>),
|
||||
/// The refresh completed, but the response did not include displayable usage data.
|
||||
Unavailable,
|
||||
/// No snapshot data is currently available.
|
||||
Missing,
|
||||
}
|
||||
@@ -269,7 +271,7 @@ pub(crate) fn compose_rate_limit_data_many(
|
||||
}
|
||||
|
||||
if rows.is_empty() {
|
||||
StatusRateLimitData::Available(vec![])
|
||||
StatusRateLimitData::Unavailable
|
||||
} else if stale {
|
||||
StatusRateLimitData::Stale(rows)
|
||||
} else {
|
||||
|
||||
-2
@@ -1,6 +1,5 @@
|
||||
---
|
||||
source: tui/src/status/tests.rs
|
||||
assertion_line: 765
|
||||
expression: sanitized
|
||||
---
|
||||
/status
|
||||
@@ -20,5 +19,4 @@ expression: sanitized
|
||||
│ Context window: 100% left (750 used / 272K) │
|
||||
│ 5h limit: [███████████░░░░░░░░░] 55% left (resets 08:24) │
|
||||
│ Weekly limit: [██████████████░░░░░░] 70% left (resets 08:54) │
|
||||
│ Notice: refreshing limits in background... │
|
||||
╰───────────────────────────────────────────────────────────────────────╯
|
||||
|
||||
+1
-1
@@ -17,5 +17,5 @@ expression: sanitized
|
||||
│ │
|
||||
│ Token usage: 750 total (500 input + 250 output) │
|
||||
│ Context window: 100% left (750 used / 272K) │
|
||||
│ Limits: data not available yet │
|
||||
│ Limits: not available for this account │
|
||||
╰───────────────────────────────────────────────────────────────────────╯
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
---
|
||||
source: tui/src/status/tests.rs
|
||||
expression: sanitized
|
||||
---
|
||||
/status
|
||||
|
||||
╭───────────────────────────────────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │
|
||||
│ information on rate limits and credits │
|
||||
│ │
|
||||
│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Permissions: Custom (read-only, on-request) │
|
||||
│ Agents.md: <none> │
|
||||
│ │
|
||||
│ Token usage: 750 total (500 input + 250 output) │
|
||||
│ Context window: 100% left (750 used / 272K) │
|
||||
│ Limits: not available for this account │
|
||||
╰───────────────────────────────────────────────────────────────────────╯
|
||||
@@ -835,7 +835,7 @@ async fn status_snapshot_includes_credits_and_limits() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_snapshot_shows_empty_limits_message() {
|
||||
async fn status_snapshot_shows_unavailable_limits_message() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
let mut config = test_config(&temp_home).await;
|
||||
config.model = Some("gpt-5.1-codex-max".to_string());
|
||||
@@ -891,6 +891,63 @@ async fn status_snapshot_shows_empty_limits_message() {
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
let mut config = test_config(&temp_home).await;
|
||||
config.model = Some("gpt-5.1-codex-max".to_string());
|
||||
config.cwd = PathBuf::from("/workspace/tests").abs();
|
||||
|
||||
let usage = TokenUsage {
|
||||
input_tokens: 500,
|
||||
cached_input_tokens: 0,
|
||||
output_tokens: 250,
|
||||
reasoning_output_tokens: 0,
|
||||
total_tokens: 750,
|
||||
};
|
||||
|
||||
let snapshot = RateLimitSnapshot {
|
||||
limit_id: None,
|
||||
limit_name: None,
|
||||
primary: None,
|
||||
secondary: None,
|
||||
credits: None,
|
||||
plan_type: None,
|
||||
};
|
||||
let captured_at = chrono::Local
|
||||
.with_ymd_and_hms(2024, 6, 7, 8, 9, 10)
|
||||
.single()
|
||||
.expect("timestamp");
|
||||
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
|
||||
|
||||
let model_slug = codex_core::test_support::get_model_offline(config.model.as_deref());
|
||||
let token_info = token_info_for(&model_slug, &config, &usage);
|
||||
let composite = new_status_output_with_rate_limits(
|
||||
&config,
|
||||
/*account_display*/ None,
|
||||
Some(&token_info),
|
||||
&usage,
|
||||
&None,
|
||||
/*thread_name*/ None,
|
||||
/*forked_from*/ None,
|
||||
std::slice::from_ref(&rate_display),
|
||||
None,
|
||||
captured_at,
|
||||
&model_slug,
|
||||
/*collaboration_mode*/ None,
|
||||
/*reasoning_effort_override*/ None,
|
||||
/*refreshing_rate_limits*/ true,
|
||||
);
|
||||
let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80));
|
||||
if cfg!(windows) {
|
||||
for line in &mut rendered_lines {
|
||||
*line = line.replace('\\', "/");
|
||||
}
|
||||
}
|
||||
let sanitized = sanitize_directory(rendered_lines).join("\n");
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_snapshot_shows_stale_limits_message() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
|
||||
Reference in New Issue
Block a user