diff --git a/docs/media/tui-lock-v2/120x40/offline.png b/docs/media/tui-lock-v2/120x40/offline.png new file mode 100644 index 00000000..68f7df68 Binary files /dev/null and b/docs/media/tui-lock-v2/120x40/offline.png differ diff --git a/docs/media/tui-lock-v2/120x40/rate-limit.png b/docs/media/tui-lock-v2/120x40/rate-limit.png new file mode 100644 index 00000000..019ed5f0 Binary files /dev/null and b/docs/media/tui-lock-v2/120x40/rate-limit.png differ diff --git a/docs/media/tui-lock-v2/40x12/offline.png b/docs/media/tui-lock-v2/40x12/offline.png new file mode 100644 index 00000000..daba35b6 Binary files /dev/null and b/docs/media/tui-lock-v2/40x12/offline.png differ diff --git a/docs/media/tui-lock-v2/40x12/rate-limit.png b/docs/media/tui-lock-v2/40x12/rate-limit.png new file mode 100644 index 00000000..775f4396 Binary files /dev/null and b/docs/media/tui-lock-v2/40x12/rate-limit.png differ diff --git a/docs/media/tui-lock-v2/SPEC.md b/docs/media/tui-lock-v2/SPEC.md index 2d5fa89b..6107e15d 100644 --- a/docs/media/tui-lock-v2/SPEC.md +++ b/docs/media/tui-lock-v2/SPEC.md @@ -7,7 +7,7 @@ categorised rows + tip/nav footer, slash autocomplete above the composer, effort radios under `/model`, token counter top-right, footer shortcut strip) and re-skinned to the Cortex chrome. **No runtime code changes ship with this pack.** -- Boards: [`index.md`](index.md) — 82 boards at 120×40, 36 of them also at 40×12 (118 PNGs). +- Boards: [`index.md`](index.md) — 84 boards at 120×40, 38 of them also at 40×12 (122 PNGs). - Grids: `txt//.txt` — the exact character grid of every board (diff a `MockTerminal` capture against these). - Renderer: `tools/render_lock_v2.py` + `tools/boards.py` (Python 3 + Pillow, IBM Plex Mono @@ -414,7 +414,7 @@ Narrow: no bars, `used / total pct%`. | settings-appearance / mouse / row-hover / search / theme-submenu | `settings-appearance`, `settings-mouse`, `settings-row-hover`, `settings-search`, `settings-theme-submenu` | | mode chips (Shift+Tab) | `mode-agent`, `mode-plan`, `mode-ask`, `mode-bash` | | permissions / mcp / plugins / usage-quota / sandbox / cloud | `permission-prompt`, `permission-prompt-hover`, `permissions-picker`, `mcp-servers`, `mcp-drop`, `plugins`, `usage`, `quota-exhausted`, `sandbox`, `sandbox-deny`, `cloud-handoff` | -| diagnostics red/amber · interrupt/stopped | `diagnostics`, `interrupt-stopped`, `error-unavailable` | +| diagnostics red/amber · interrupt/stopped | `diagnostics`, `interrupt-stopped`, `error-unavailable`, `offline`, `rate-limit` | | markdown table · diff hunk · code fence | `md-table`, `diff-hunk`, `edit-collapsed`, `code-fence`, `tool-tiles`, `tool-tiles-collapsed`, `shell-running` | | login / first-run | `login`, `login-waiting`, `login-success`, `login-error`, `first-run-tips` | | compact chat (ref 5) | `compact-chat` | @@ -425,7 +425,7 @@ Narrow (40×12) set: `welcome-cortex`, `welcome-agent`, `first-run-tips`, `sessi `tokens-topright`, `compact-chat`, `slash-palette`, `goal-chip-active`, `goal-chip-paused`, `goal-chip-done`, `goal-chip-budget`, `goal-chip-blocked`, `slash-model-typed`, `model-list`, `model-effort-high`, `settings-appearance`, `settings-mouse`, `settings-row-hover`, `settings-theme-submenu`, `mode-plan`, `mode-ask`, -`permission-prompt`, `mcp-servers`, `usage`, `diagnostics`, `interrupt-stopped`, `diff-hunk`, `login`, `shortcuts-overlay`. +`permission-prompt`, `mcp-servers`, `usage`, `diagnostics`, `interrupt-stopped`, `offline`, `rate-limit`, `diff-hunk`, `login`, `shortcuts-overlay`. --- diff --git a/docs/media/tui-lock-v2/index.md b/docs/media/tui-lock-v2/index.md index e27926f3..8ee26a96 100644 --- a/docs/media/tui-lock-v2/index.md +++ b/docs/media/tui-lock-v2/index.md @@ -88,6 +88,8 @@ Regenerate: `python3 tools/render_lock_v2.py --index` (fetches IBM Plex Mono on | `diagnostics` | Diagnostics tile — error red, warn amber | [120x40](120x40/diagnostics.png) | [40x12](40x12/diagnostics.png) | | `interrupt-stopped` | Esc / Ctrl+c — `× Stopped` | [120x40](120x40/interrupt-stopped.png) | [40x12](40x12/interrupt-stopped.png) | | `error-unavailable` | API down — product-facing error | [120x40](120x40/error-unavailable.png) | — | +| `offline` | Network unreachable — held composer, distinct from API-down | [120x40](120x40/offline.png) | [40x12](40x12/offline.png) | +| `rate-limit` | HTTP 429 rate limited — retry-after, distinct from quota | [120x40](120x40/rate-limit.png) | [40x12](40x12/rate-limit.png) | | `tool-tiles` | Grouped tool calls expanded — Read / Grep / Shell | [120x40](120x40/tool-tiles.png) | — | | `tool-tiles-collapsed` | Grouped tool calls collapsed | [120x40](120x40/tool-tiles-collapsed.png) | — | | `shell-running` | Live Shell tile with output | [120x40](120x40/shell-running.png) | — | @@ -113,4 +115,4 @@ Regenerate: `python3 tools/render_lock_v2.py --index` (fetches IBM Plex Mono on | `config-tree` | `/config` read-only key tree | [120x40](120x40/config-tree.png) | — | | `btw` | `/btw` side note during a running turn | [120x40](120x40/btw.png) | — | -**82** boards at 120x40 · **36** at 40x12 · 118 PNGs. +**84** boards at 120x40 · **38** at 40x12 · 122 PNGs. diff --git a/docs/media/tui-lock-v2/runtime/120x40/offline.png b/docs/media/tui-lock-v2/runtime/120x40/offline.png new file mode 100644 index 00000000..cb008a8d Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/offline.png differ diff --git a/docs/media/tui-lock-v2/runtime/120x40/rate-limit.png b/docs/media/tui-lock-v2/runtime/120x40/rate-limit.png new file mode 100644 index 00000000..cae748bd Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/120x40/rate-limit.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/offline.png b/docs/media/tui-lock-v2/runtime/40x12/offline.png new file mode 100644 index 00000000..65c70d3f Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/offline.png differ diff --git a/docs/media/tui-lock-v2/runtime/40x12/rate-limit.png b/docs/media/tui-lock-v2/runtime/40x12/rate-limit.png new file mode 100644 index 00000000..55cbd11f Binary files /dev/null and b/docs/media/tui-lock-v2/runtime/40x12/rate-limit.png differ diff --git a/docs/media/tui-lock-v2/runtime/README.md b/docs/media/tui-lock-v2/runtime/README.md index e3c3959b..4955b603 100644 --- a/docs/media/tui-lock-v2/runtime/README.md +++ b/docs/media/tui-lock-v2/runtime/README.md @@ -8,6 +8,6 @@ is banner green `#1F4945`; historical violet `#A78BFA` is not the lock. Designer boards (pixel target) live in `docs/media/tui-lock-v2/{40x12,120x40}/`. These runtime frames are what Designer cli signs off against. -SPEC §7: **82** boards at 120×40 and **36** at 40×12. Each filename is one +SPEC §7: **84** boards at 120×40 and **38** at 40×12. Each filename is one distinct live state — no two PNGs share a sha256. Includes `/goal` composer -chips (`goal-chip-*`) and slash palette with `/goal` after `/plan`. +chips (`goal-chip-*`) plus distinct `offline` and `rate-limit` diagnostics. diff --git a/docs/media/tui-lock-v2/tools/boards.py b/docs/media/tui-lock-v2/tools/boards.py index ebac61dc..1c6f6e3c 100644 --- a/docs/media/tui-lock-v2/tools/boards.py +++ b/docs/media/tui-lock-v2/tools/boards.py @@ -1380,6 +1380,38 @@ def board_error_unavailable(s, c): footer(s, c, [("Enter", "retry"), ("Ctrl+x", "shortcuts")]) +def board_offline(s, c): + header(s, c) + hold = "Waiting for network" if c.narrow else "Waiting for network — your work is saved" + top = composer(s, c, placeholder=hold, focused=False, caret=False) + f = Flow(s, c, limit=top - 1) + f.user("keep going on this Cortex session", "03:18 PM") + f.line([("×", S_ERR), (" You're offline", S_ERR)]) + body = ( + "Reconnect — your work is saved." + if c.narrow + else "Reconnect when the network is back — your work is saved in this session." + ) + f.dim(body, indent=2) + footer(s, c, [("Enter", "retry"), ("Ctrl+x", "shortcuts")]) + + +def board_rate_limit(s, c): + header(s, c) + hold = "Held until the rate limit resets" if c.narrow else "Add a follow-up — held until the rate limit resets" + top = composer(s, c, placeholder=hold, focused=False, caret=False) + f = Flow(s, c, limit=top - 1) + f.user("send another follow-up on Cortex Mini 1", "03:22 PM") + f.line([("×", S_ERR), (" Rate limited", S_ERR)]) + body = ( + "Too many requests — try again in 2m." + if c.narrow + else "Too many requests — try again in 2m (Retry-After: 120s). Follow-ups stay held until the rate limit resets." + ) + f.dim(body, indent=2) + footer(s, c, [("Enter", "retry"), ("/usage", "details")]) + + def board_tool_tiles(s, c): header(s, c) top = composer(s, c) @@ -1856,6 +1888,8 @@ def board_btw(s, c): ("diagnostics", board_diagnostics, True, "E", "Diagnostics tile — error red, warn amber"), ("interrupt-stopped", board_interrupt_stopped, True, "E", "Esc / Ctrl+c — `× Stopped`"), ("error-unavailable", board_error_unavailable, False, "E", "API down — product-facing error"), + ("offline", board_offline, True, "E", "Network unreachable — held composer, distinct from API-down"), + ("rate-limit", board_rate_limit, True, "E", "HTTP 429 rate limited — retry-after, distinct from quota"), ("tool-tiles", board_tool_tiles, False, "E", "Grouped tool calls expanded — Read / Grep / Shell"), ("tool-tiles-collapsed", board_tool_tiles_collapsed, False, "E", "Grouped tool calls collapsed"), ("shell-running", board_shell_running, False, "E", "Live Shell tile with output"), diff --git a/docs/media/tui-lock-v2/txt/120x40/offline.txt b/docs/media/tui-lock-v2/txt/120x40/offline.txt new file mode 100644 index 00000000..a47b175f --- /dev/null +++ b/docs/media/tui-lock-v2/txt/120x40/offline.txt @@ -0,0 +1,40 @@ + 14K / 500K + + > keep going on this Cortex session 03:18 PM + + × You're offline + Reconnect when the network is back — your work is saved in this session. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭─ Agent ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ > Waiting for network — your work is saved │ + ╰─────────────────────────────────────────────────────────────────────────────────────────── Cortex Mini 1 (medium) ─╯ + + Enter:retry | Ctrl+x:shortcuts diff --git a/docs/media/tui-lock-v2/txt/120x40/rate-limit.txt b/docs/media/tui-lock-v2/txt/120x40/rate-limit.txt new file mode 100644 index 00000000..f4b7119e --- /dev/null +++ b/docs/media/tui-lock-v2/txt/120x40/rate-limit.txt @@ -0,0 +1,40 @@ + 14K / 500K + + > send another follow-up on Cortex Mini 1 03:22 PM + + × Rate limited + Too many requests — try again in 2m (Retry-After: 120s). Follow-ups stay held until the rate limit resets. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ╭─ Agent ────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ + │ > Add a follow-up — held until the rate limit resets │ + ╰─────────────────────────────────────────────────────────────────────────────────────────── Cortex Mini 1 (medium) ─╯ + + Enter:retry | /usage:details diff --git a/docs/media/tui-lock-v2/txt/40x12/offline.txt b/docs/media/tui-lock-v2/txt/40x12/offline.txt new file mode 100644 index 00000000..e1841cbe --- /dev/null +++ b/docs/media/tui-lock-v2/txt/40x12/offline.txt @@ -0,0 +1,12 @@ + 14K / 500K + + > keep going on this Cortex session + + × You're offline + Reconnect — your work is saved. + + ╭─ Agent ────────────────────────────╮ + │ > Waiting for network │ + ╰─────────── Cortex Mini 1 (medium) ─╯ + + Enter:retry | Ctrl+x:shortcuts diff --git a/docs/media/tui-lock-v2/txt/40x12/rate-limit.txt b/docs/media/tui-lock-v2/txt/40x12/rate-limit.txt new file mode 100644 index 00000000..3f52e88e --- /dev/null +++ b/docs/media/tui-lock-v2/txt/40x12/rate-limit.txt @@ -0,0 +1,12 @@ + 14K / 500K + + > send another follow-up on Cortex… + + × Rate limited + Too many requests — try again in… + + ╭─ Agent ────────────────────────────╮ + │ > Held until the rate limit resets │ + ╰─────────── Cortex Mini 1 (medium) ─╯ + + Enter:retry | /usage:details diff --git a/scripts/render-tui-lock-v2.sh b/scripts/render-tui-lock-v2.sh index b9006586..6a13db4f 100755 --- a/scripts/render-tui-lock-v2.sh +++ b/scripts/render-tui-lock-v2.sh @@ -59,8 +59,8 @@ for spec in 40x12 120x40; do python3 scripts/ansi-frames-to-gif.py --frames "$frames" --png-only "$pngs" done -unique_pngs "$output_dir/40x12" 36 -unique_pngs "$output_dir/120x40" 82 +unique_pngs "$output_dir/40x12" 38 +unique_pngs "$output_dir/120x40" 84 python3 - "$output_dir" <<'PY' from pathlib import Path @@ -100,8 +100,9 @@ is banner green `#1F4945`; historical violet `#A78BFA` is not the lock. Designer boards (pixel target) live in `docs/media/tui-lock-v2/{40x12,120x40}/`. These runtime frames are what Designer cli signs off against. -SPEC §7: **82** boards at 120×40 and **36** at 40×12. Each filename is one -distinct live state — no two PNGs share a sha256. +SPEC §7: **84** boards at 120×40 and **38** at 40×12. Each filename is one +distinct live state — no two PNGs share a sha256. Includes `/goal` composer +chips (`goal-chip-*`) plus distinct `offline` and `rate-limit` diagnostics. EOF echo "Wrote runtime captures to $output_dir" diff --git a/src/cortex-tui/src/app/state.rs b/src/cortex-tui/src/app/state.rs index 49aa8045..a9a60d62 100644 --- a/src/cortex-tui/src/app/state.rs +++ b/src/cortex-tui/src/app/state.rs @@ -202,6 +202,10 @@ pub struct AppState { pub update_info: Option, /// Agent quota is exhausted — follow-ups stay in the composer until it resets. pub quota_held: bool, + /// Network is unreachable — follow-ups stay in the composer until reconnect. + pub offline_held: bool, + /// HTTP 429 rate limit — follow-ups stay in the composer until Retry-After. + pub rate_limit_held: bool, /// Launched via the `agent` binary / alias. pub agent_entrypoint: bool, /// Token counter used / window. @@ -342,6 +346,8 @@ impl AppState { update_status: UpdateStatus::default(), update_info: None, quota_held: false, + offline_held: false, + rate_limit_held: false, agent_entrypoint: false, tokens_used: 0, context_window: 500_000, diff --git a/src/cortex-tui/src/lib.rs b/src/cortex-tui/src/lib.rs index 64f493c3..4e90462b 100644 --- a/src/cortex-tui/src/lib.rs +++ b/src/cortex-tui/src/lib.rs @@ -107,6 +107,7 @@ pub mod lock_v2; mod lock_v2_boards; mod lock_v2_goal; mod lock_v2_ids; +mod lock_v2_network; mod lock_v2_scenes; pub mod readme_hero; pub mod readme_hero_boards; diff --git a/src/cortex-tui/src/lock_v2.rs b/src/cortex-tui/src/lock_v2.rs index d6c1dadb..6e659f86 100644 --- a/src/cortex-tui/src/lock_v2.rs +++ b/src/cortex-tui/src/lock_v2.rs @@ -128,8 +128,8 @@ mod tests { #[test] fn lock_v2_wide_count_is_spec() { - assert_eq!(LOCK_V2_WIDE_IDS.len(), 82); - assert_eq!(LOCK_V2_NARROW_IDS.len(), 36); + assert_eq!(LOCK_V2_WIDE_IDS.len(), 84); + assert_eq!(LOCK_V2_NARROW_IDS.len(), 38); } #[test] diff --git a/src/cortex-tui/src/lock_v2_boards.rs b/src/cortex-tui/src/lock_v2_boards.rs index 49305366..f021226a 100644 --- a/src/cortex-tui/src/lock_v2_boards.rs +++ b/src/cortex-tui/src/lock_v2_boards.rs @@ -15,6 +15,7 @@ use crate::interactive::builders::{ }; use crate::lock_v2::PRODUCT_ERROR; use crate::lock_v2_goal::{apply_goal_chip_scene, show_goal_in_narrow_palette}; +use crate::lock_v2_network::apply_offline_rate_limit_scene; use crate::lock_v2_scenes::*; use crate::modal::mcp_manager::{McpServerInfo, McpStatus}; use crate::session::SessionSummary; @@ -806,6 +807,7 @@ Tell me what you'd like to do.", Message::user("/btw keep the composer dual-hairline").with_timestamp("09:51 AM"), ); } + id if apply_offline_rate_limit_scene(id, &mut state) => {} id if apply_goal_chip_scene(id, &mut state) => {} other => panic!("unknown lock v2 scene {other}"), } diff --git a/src/cortex-tui/src/lock_v2_ids.rs b/src/cortex-tui/src/lock_v2_ids.rs index 7e067bfa..8b6274a4 100644 --- a/src/cortex-tui/src/lock_v2_ids.rs +++ b/src/cortex-tui/src/lock_v2_ids.rs @@ -1,7 +1,7 @@ //! Lock v2 scene id lists. Split out of [`crate::lock_v2`] so adding `/goal` //! boards does not grow that file past the source-policy line-count baseline. -/// Narrow (40×12) SPEC §7 set — 36 boards. +/// Narrow (40×12) SPEC §7 set — 38 boards. pub const LOCK_V2_NARROW_IDS: &[&str] = &[ "welcome-cortex", "welcome-agent", @@ -36,12 +36,14 @@ pub const LOCK_V2_NARROW_IDS: &[&str] = &[ "usage", "diagnostics", "interrupt-stopped", + "offline", + "rate-limit", "diff-hunk", "login", "shortcuts-overlay", ]; -/// Wide (120×40) SPEC §7 set — 82 boards. +/// Wide (120×40) SPEC §7 set — 84 boards. pub const LOCK_V2_WIDE_IDS: &[&str] = &[ "welcome-cortex", "welcome-agent", @@ -101,6 +103,8 @@ pub const LOCK_V2_WIDE_IDS: &[&str] = &[ "diagnostics", "interrupt-stopped", "error-unavailable", + "offline", + "rate-limit", "tool-tiles", "tool-tiles-collapsed", "shell-running", diff --git a/src/cortex-tui/src/lock_v2_network.rs b/src/cortex-tui/src/lock_v2_network.rs new file mode 100644 index 00000000..ceb06dc2 --- /dev/null +++ b/src/cortex-tui/src/lock_v2_network.rs @@ -0,0 +1,263 @@ +//! Offline and HTTP 429 rate-limit lock v2 scenes. +//! +//! Split out of [`crate::lock_v2`] so the source-policy line-count baseline +//! on that file does not regress. Distinct from API-down (`error-unavailable`) +//! and period-quota (`quota-exhausted`). + +use cortex_core::widgets::Message; + +use crate::app::AppState; + +pub const OFFLINE_TITLE: &str = "× You're offline"; +pub const OFFLINE_BODY: &str = + "Reconnect when the network is back — your work is saved in this session."; +pub const OFFLINE_BODY_NARROW: &str = "Reconnect — your work is saved."; +pub const RATE_LIMIT_TITLE: &str = "× Rate limited"; +pub const RATE_LIMIT_BODY: &str = "Too many requests — try again in 2m (Retry-After: 120s). Follow-ups stay held until the rate limit resets."; +pub const RATE_LIMIT_BODY_NARROW: &str = "Too many requests — try again in 2m."; + +/// Apply an offline or rate-limit lock scene. Returns `false` when `id` is neither. +pub fn apply_offline_rate_limit_scene(id: &str, state: &mut AppState) -> bool { + let narrow = state.terminal_size.0 <= 40; + match id { + "offline" => { + state.show_launch_splash = false; + state.tokens_used = 14_000; + state.offline_held = true; + state.add_message( + Message::user("keep going on this Cortex session").with_timestamp("03:18 PM"), + ); + state.add_message(Message::system(OFFLINE_TITLE)); + state.add_message(Message::system(if narrow { + OFFLINE_BODY_NARROW + } else { + OFFLINE_BODY + })); + true + } + "rate-limit" => { + state.show_launch_splash = false; + state.tokens_used = 14_000; + state.rate_limit_held = true; + state.add_message( + Message::user("send another follow-up on Cortex Mini 1").with_timestamp("03:22 PM"), + ); + state.add_message(Message::system(RATE_LIMIT_TITLE)); + state.add_message(Message::system(if narrow { + RATE_LIMIT_BODY_NARROW + } else { + RATE_LIMIT_BODY + })); + true + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::lock_v2::render_lock_v2_scene; + use crate::ui::consts::{ + PLACEHOLDER_OFFLINE, PLACEHOLDER_OFFLINE_NARROW, PLACEHOLDER_QUOTA, PLACEHOLDER_RATE_LIMIT, + PLACEHOLDER_RATE_LIMIT_NARROW, + }; + use cortex_core::style::{ERROR, TEXT_DIM}; + use ratatui::style::Color; + + fn first_fg(frame: &crate::lock_proof::LockFrame, needle: &str) -> Option { + let width = frame.buffer.area.width; + let height = frame.buffer.area.height; + for y in 0..height { + let mut row = String::new(); + for x in 0..width { + row.push_str(frame.buffer[(x, y)].symbol()); + } + if let Some(idx) = row.find(needle) { + return Some(frame.buffer[(idx as u16, y)].fg); + } + } + None + } + + #[test] + fn offline_and_rate_limit_are_distinct_from_api_and_quota() { + let offline = render_lock_v2_scene("offline", 120, 40).expect("offline"); + let rate = render_lock_v2_scene("rate-limit", 120, 40).expect("rate-limit"); + let unavail = render_lock_v2_scene("error-unavailable", 120, 40).expect("unavail"); + let quota = render_lock_v2_scene("quota-exhausted", 120, 40).expect("quota"); + + assert!( + offline.plain.contains("You're offline"), + "offline title:\n{}", + offline.plain + ); + assert!( + offline.plain.contains(OFFLINE_BODY), + "offline body:\n{}", + offline.plain + ); + assert!( + !offline.plain.contains("temporarily unavailable"), + "offline must not reuse the API-down line:\n{}", + offline.plain + ); + assert!( + offline.plain.contains(PLACEHOLDER_OFFLINE), + "offline held composer:\n{}", + offline.plain + ); + assert!( + offline.plain.contains("Enter:retry") && offline.plain.contains("Ctrl+x:shortcuts"), + "offline footer:\n{}", + offline.plain + ); + assert!( + !offline.plain.contains("Shift+Tab:mode"), + "offline must not use the idle footer:\n{}", + offline.plain + ); + + assert!( + rate.plain.contains("Rate limited"), + "rate-limit title:\n{}", + rate.plain + ); + assert!( + rate.plain.contains("Retry-After") || rate.plain.contains("try again in 2m"), + "rate-limit retry-after:\n{}", + rate.plain + ); + assert!( + !rate.plain.contains("Agent quota exhausted") && !rate.plain.contains("500 / 500"), + "rate-limit must not reuse the quota board:\n{}", + rate.plain + ); + assert!( + !rate.plain.contains("upgrade at cortex.foundation/billing"), + "rate-limit must not lead with a billing upgrade CTA:\n{}", + rate.plain + ); + assert!( + rate.plain.contains(PLACEHOLDER_RATE_LIMIT), + "rate-limit held composer:\n{}", + rate.plain + ); + assert!( + !rate.plain.contains(PLACEHOLDER_QUOTA), + "rate-limit must not reuse the quota placeholder:\n{}", + rate.plain + ); + assert!( + rate.plain.contains("Enter:retry") && rate.plain.contains("/usage:details"), + "rate-limit footer:\n{}", + rate.plain + ); + assert!( + !rate.plain.contains("Shift+Tab:mode"), + "rate-limit must not use the idle footer:\n{}", + rate.plain + ); + + assert_eq!(first_fg(&offline, "You're offline"), Some(ERROR)); + assert_eq!(first_fg(&offline, "Reconnect"), Some(TEXT_DIM)); + assert_eq!(first_fg(&rate, "Rate limited"), Some(ERROR)); + assert_eq!(first_fg(&rate, "Too many requests"), Some(TEXT_DIM)); + assert_eq!(first_fg(&unavail, "temporarily unavailable"), Some(ERROR)); + + assert_ne!(offline.ansi, unavail.ansi); + assert_ne!(rate.ansi, quota.ansi); + assert_ne!(offline.ansi, rate.ansi); + + for (width, height) in [(120u16, 40u16), (40u16, 12u16)] { + let a = render_lock_v2_scene("offline", width, height).expect("offline"); + let b = render_lock_v2_scene("rate-limit", width, height).expect("rate-limit"); + assert_ne!( + a.ansi, b.ansi, + "offline and rate-limit collide at {width}x{height}" + ); + assert!( + a.plain.contains("offline") || a.plain.contains("You're offline"), + "offline at {width}x{height}:\n{}", + a.plain + ); + assert!( + b.plain.contains("Rate limited") || b.plain.contains("Too many requests"), + "rate-limit at {width}x{height}:\n{}", + b.plain + ); + assert_eq!( + first_fg(&a, "You're offline"), + Some(ERROR), + "offline title red at {width}x{height}" + ); + assert_eq!( + first_fg(&b, "Rate limited"), + Some(ERROR), + "rate-limit title red at {width}x{height}" + ); + assert_eq!( + first_fg(&b, "Too many requests"), + Some(TEXT_DIM), + "rate-limit body dim at {width}x{height}" + ); + } + + let offline_n = render_lock_v2_scene("offline", 40, 12).expect("offline narrow"); + assert!( + offline_n.plain.contains(OFFLINE_BODY_NARROW), + "narrow offline body:\n{}", + offline_n.plain + ); + assert!( + !offline_n.plain.contains("when the network is back"), + "narrow offline must use the short body:\n{}", + offline_n.plain + ); + assert!( + offline_n.plain.contains(PLACEHOLDER_OFFLINE_NARROW), + "narrow offline placeholder:\n{}", + offline_n.plain + ); + assert_eq!(first_fg(&offline_n, "Reconnect"), Some(TEXT_DIM)); + + let rate_n = render_lock_v2_scene("rate-limit", 40, 12).expect("rate-limit narrow"); + assert!( + rate_n.plain.contains(PLACEHOLDER_RATE_LIMIT_NARROW) + || rate_n.plain.contains("rate limit resets"), + "narrow rate-limit placeholder:\n{}", + rate_n.plain + ); + assert!( + rate_n.plain.contains("try again in 2m"), + "narrow rate-limit retry timing:\n{}", + rate_n.plain + ); + assert!( + !rate_n.plain.contains("Retry-After"), + "narrow rate-limit must use the compact body:\n{}", + rate_n.plain + ); + assert!( + rate_n.plain.contains("Enter:retry") && rate_n.plain.contains("/usage:details"), + "narrow rate-limit footer:\n{}", + rate_n.plain + ); + } + + #[test] + fn apply_rejects_unknown_ids() { + let mut state = AppState::default(); + assert!(!apply_offline_rate_limit_scene( + "error-unavailable", + &mut state + )); + assert!(!apply_offline_rate_limit_scene( + "quota-exhausted", + &mut state + )); + assert!(!state.offline_held); + assert!(!state.rate_limit_held); + assert!(!state.quota_held); + } +} diff --git a/src/cortex-tui/src/ui/chrome.rs b/src/cortex-tui/src/ui/chrome.rs index 6636302e..c5243863 100644 --- a/src/cortex-tui/src/ui/chrome.rs +++ b/src/cortex-tui/src/ui/chrome.rs @@ -250,6 +250,7 @@ pub enum FooterSet { Resume, Bash, Unavailable, + RateLimit, Palette, } @@ -454,6 +455,16 @@ impl FooterSet { label: "shortcuts", }, ], + Self::RateLimit => &[ + FooterHint { + key: "Enter", + label: "retry", + }, + FooterHint { + key: "/usage", + label: "details", + }, + ], Self::Palette => &[ FooterHint { key: "Enter", diff --git a/src/cortex-tui/src/ui/consts.rs b/src/cortex-tui/src/ui/consts.rs index 80e9885c..659f690f 100644 --- a/src/cortex-tui/src/ui/consts.rs +++ b/src/cortex-tui/src/ui/consts.rs @@ -49,6 +49,16 @@ pub const PLACEHOLDER_QUOTA: &str = "Add a follow-up — held until quota resets /// Narrow (40-column) form of the quota placeholder. pub const PLACEHOLDER_QUOTA_NARROW: &str = "Follow-up — held until quota resets"; +/// Composer placeholder while the session is offline. +pub const PLACEHOLDER_OFFLINE: &str = "Waiting for network — your work is saved"; +/// Narrow (40-column) form of the offline placeholder. +pub const PLACEHOLDER_OFFLINE_NARROW: &str = "Waiting for network"; + +/// Composer placeholder while HTTP 429 holds follow-ups. +pub const PLACEHOLDER_RATE_LIMIT: &str = "Add a follow-up — held until the rate limit resets"; +/// Narrow (40-column) form of the rate-limit placeholder. +pub const PLACEHOLDER_RATE_LIMIT_NARROW: &str = "Held until the rate limit resets"; + /// Sandbox deny title painted in error red. pub const SANDBOX_DENIED_TITLE: &str = "Sandbox denied"; diff --git a/src/cortex-tui/src/views/minimal_session/rendering.rs b/src/cortex-tui/src/views/minimal_session/rendering.rs index 10e99421..adacb9fb 100644 --- a/src/cortex-tui/src/views/minimal_session/rendering.rs +++ b/src/cortex-tui/src/views/minimal_session/rendering.rs @@ -120,22 +120,7 @@ pub fn render_message_with_theme( } } MessageRole::System => { - // Detect error messages - show in error color - let is_error = msg.content.contains("Check your") - || msg.content.contains("temporarily unavailable") - || msg.content.contains("Access denied") - || msg.content.contains("timed out") - || msg.content.contains("failed") - || msg.content.contains("Invalid") - || msg.content.contains("limit") - || msg.content.contains("Stopped") - || msg.content.contains("quota exhausted") - || msg.content.contains("Sandbox denied") - || msg.content.contains("dropped") - || msg.content.starts_with("Error:") - || msg.content.contains("provider appears to be overloaded") - || msg.content.contains("internet connection") - || msg.content.contains("proxy is experiencing issues"); + let is_error = super::text_utils::system_message_is_error(&msg.content); // Errors in lock red; other system copy stays dim gray. let text_color = if is_error { diff --git a/src/cortex-tui/src/views/minimal_session/text_utils.rs b/src/cortex-tui/src/views/minimal_session/text_utils.rs index 19282b1f..de821f16 100644 --- a/src/cortex-tui/src/views/minimal_session/text_utils.rs +++ b/src/cortex-tui/src/views/minimal_session/text_utils.rs @@ -2,6 +2,31 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +/// Lock-red system copy: API-down, offline, HTTP 429, quota, sandbox, and similar. +pub(super) fn system_message_is_error(content: &str) -> bool { + content.starts_with("Error:") + || [ + "Check your", + "temporarily unavailable", + "Access denied", + "timed out", + "failed", + "Invalid", + "You're offline", + "Rate limited", + "context limit", + "Stopped", + "quota exhausted", + "Sandbox denied", + "dropped", + "provider appears to be overloaded", + "internet connection", + "proxy is experiencing issues", + ] + .iter() + .any(|needle| content.contains(needle)) +} + /// Wraps text to fit within a maximum width (measured in visual columns). /// /// This function performs word wrapping, breaking lines at word boundaries @@ -140,3 +165,24 @@ fn split_long_word_returning_last( None } } + +#[cfg(test)] +mod tests { + use super::system_message_is_error; + + #[test] + fn lock_red_system_copy_includes_offline_and_rate_limit() { + assert!(system_message_is_error("× You're offline")); + assert!(system_message_is_error("× Rate limited")); + assert!(system_message_is_error( + "The coding service is temporarily unavailable" + )); + assert!(system_message_is_error("context limit reached")); + assert!(!system_message_is_error( + "Reconnect when the network is back — your work is saved in this session." + )); + assert!(!system_message_is_error( + "Too many requests — try again in 2m (Retry-After: 120s). Follow-ups stay held until the rate limit resets." + )); + } +} diff --git a/src/cortex-tui/src/views/minimal_session/view.rs b/src/cortex-tui/src/views/minimal_session/view.rs index 51a8c4e6..4b87fd3b 100644 --- a/src/cortex-tui/src/views/minimal_session/view.rs +++ b/src/cortex-tui/src/views/minimal_session/view.rs @@ -306,6 +306,18 @@ impl<'a> MinimalSessionView<'a> { let placeholder = if input_text.is_empty() { Some(if prompt_owns_focus { PLACEHOLDER_PROMPT + } else if self.app_state.offline_held { + if area_is_narrow(self.app_state.terminal_size.0) { + crate::ui::consts::PLACEHOLDER_OFFLINE_NARROW + } else { + crate::ui::consts::PLACEHOLDER_OFFLINE + } + } else if self.app_state.rate_limit_held { + if area_is_narrow(self.app_state.terminal_size.0) { + crate::ui::consts::PLACEHOLDER_RATE_LIMIT_NARROW + } else { + crate::ui::consts::PLACEHOLDER_RATE_LIMIT + } } else if self.app_state.quota_held { crate::ui::consts::PLACEHOLDER_QUOTA } else if self.is_task_running() { @@ -720,9 +732,12 @@ pub const PALETTE_FOOTER_HINT_SHORT: &str = "Enter:send | Ctrl+x:shortcuts"; impl<'a> MinimalSessionView<'a> { fn footer_set(&self, is_task_running: bool, width: u16) -> FooterSet { - if self.app_state.quota_held { + if self.app_state.offline_held || self.app_state.quota_held { return FooterSet::Unavailable; } + if self.app_state.rate_limit_held { + return FooterSet::RateLimit; + } if let Some(state) = self.app_state.get_interactive_state() { if state.effort_focused { return FooterSet::Effort;