diff --git a/CHANGELOG.md b/CHANGELOG.md index a245c58768..4dfd55adbe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 label as a child that asked a question. Parked work sorts below live and answerable work and no longer inflates the `blocked` chip; the receipts roster and the wire `state` gain `parked` (#5906, #5921). +- The bottom chrome no longer prints facts it cannot stand behind. A route + that cannot prove its effective reasoning tier — typically a custom + OpenAI-compatible gateway with no endpoint receipt — states no effort + field in the metrics line rather than the placeholder + `high→effective unavailable`; `/status` still tells the full story. The + cost segment is omitted only where the route itself cannot be priced + (`cost: unknown` stays on metered routes that merely lack a reading this + session, per #5578) (#5950). - `codewhale account keys set|remove|list` no longer carry a hardcoded eight-provider list. Provider ids come from the control plane's public @@ -153,6 +161,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 per-World session container before each command — full tree first, then only changes and deletions — so remote builds and tests run on the files just edited locally and their outputs persist across commands. +- `[tui].posture_bar` and `[tui].metrics_line` size the two rows under the + composer: `full` (the default), `compact`, or `hidden`, also settable at + runtime with `/config posture_bar compact`. `compact` is the row after its + first shed rungs — the posture bar keeps its permission and mode chips and + the cap warning but drops the clocks, counts and hint; the metrics line + keeps the route, context reading, cost and balance but drops the telemetry + and the help hint — and `hidden` gives the row back to the transcript. + Composition stays in `tui.status_items`; these presets only decide how + much of a row paints, reusing the rows' existing shed ladders rather than + a second renderer (#5950). ## [0.9.12] - 2026-09-03 diff --git a/config.example.toml b/config.example.toml index 2647e233e2..c6d8b9445d 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1126,6 +1126,17 @@ osc8_links = true # emit OSC 8 escapes around URLs (Cmd+click in iTer # git_branch, last_tool_elapsed, rate_limit — they drove nothing. Old files # keep loading; the retired keys are ignored. # status_items = ["mode", "model", "context_percent", "cost", "tokens"] +# Size presets for the two rows themselves (#5950) — composition stays in +# status_items; these only decide how much of a row paints: +# posture_bar = "full" # full | compact | hidden (default full) +# # compact keeps the posture chips (and the cap +# # warning) and drops the clocks, counts and hint; +# # hidden gives the row to the transcript. +# metrics_line = "full" # full | compact | hidden (default full) +# # compact keeps the route, context reading, cost +# # and balance and drops the telemetry and the +# # help hint; hidden gives the row to the transcript. +# # Also settable at runtime: /config posture_bar compact # notification_condition = "unfocused" # unfocused | always | never # "unfocused" = notify only after this terminal has been # in the background for two seconds (default); diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 3682faa175..d28cb57806 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -29,6 +29,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 label as a child that asked a question. Parked work sorts below live and answerable work and no longer inflates the `blocked` chip; the receipts roster and the wire `state` gain `parked` (#5906, #5921). +- The bottom chrome no longer prints facts it cannot stand behind. A route + that cannot prove its effective reasoning tier — typically a custom + OpenAI-compatible gateway with no endpoint receipt — states no effort + field in the metrics line rather than the placeholder + `high→effective unavailable`; `/status` still tells the full story. The + cost segment is omitted only where the route itself cannot be priced + (`cost: unknown` stays on metered routes that merely lack a reading this + session, per #5578) (#5950). - `codewhale account keys set|remove|list` no longer carry a hardcoded eight-provider list. Provider ids come from the control plane's public @@ -153,6 +161,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 per-World session container before each command — full tree first, then only changes and deletions — so remote builds and tests run on the files just edited locally and their outputs persist across commands. +- `[tui].posture_bar` and `[tui].metrics_line` size the two rows under the + composer: `full` (the default), `compact`, or `hidden`, also settable at + runtime with `/config posture_bar compact`. `compact` is the row after its + first shed rungs — the posture bar keeps its permission and mode chips and + the cap warning but drops the clocks, counts and hint; the metrics line + keeps the route, context reading, cost and balance but drops the telemetry + and the help hint — and `hidden` gives the row back to the transcript. + Composition stays in `tui.status_items`; these presets only decide how + much of a row paints, reusing the rows' existing shed ladders rather than + a second renderer (#5950). ## [0.9.12] - 2026-09-03 diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index 93bcb09998..bfe6e38c39 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -322,6 +322,8 @@ fn show_single_setting(app: &App, key: &str) -> CommandResult { app.active_context_window_source.display_label(), )), "stream_chunk_timeout_secs" => Some(app.stream_chunk_timeout_secs.to_string()), + "posture_bar" => Some(app.posture_bar.as_setting().to_string()), + "metrics_line" => Some(app.metrics_line.as_setting().to_string()), "locale" | "language" => Some(locale_display(app.ui_locale).to_string()), "theme" | "ui_theme" => Some( if app @@ -867,6 +869,20 @@ fn config_editability_audit(app: &App) -> CommandResult { "/config stream_chunk_timeout_secs <0|1..3600> --save", "Writes [tui].stream_chunk_timeout_secs and updates the running stream timeout.", ), + ( + "posture_bar", + app.posture_bar.as_setting().to_string(), + "runtime+persisted", + "/config posture_bar --save", + "Writes [tui].posture_bar; hidden gives the row to the transcript, compact keeps the posture chips only.", + ), + ( + "metrics_line", + app.metrics_line.as_setting().to_string(), + "runtime+persisted", + "/config metrics_line --save", + "Writes [tui].metrics_line; hidden gives the row to the transcript, compact drops the telemetry and help hint.", + ), ( "subagents.enabled", subagents_config_display_value(&config, "enabled"), @@ -2364,6 +2380,40 @@ pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> "provider_url must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving.", ); } + // The two bottom-chrome rows' size presets (`tui.posture_bar`, + // `tui.metrics_line`, #5950). Live on the next frame; `--save` + // writes the `[tui]` key. `/statusline` composes what is in a row; + // this only decides whether and how much of it paints. + row_key @ ("posture_bar" | "metrics_line") => { + let Some(preset) = crate::config::ChromeRowPreset::from_setting(value) else { + return CommandResult::error(format!( + "{row_key} must be one of: {}", + crate::config::ChromeRowPreset::SETTINGS.join(", ") + )); + }; + if row_key == "posture_bar" { + app.posture_bar = preset; + } else { + app.metrics_line = preset; + } + app.needs_redraw = true; + let value = preset.as_setting(); + if persist { + return match persist_table_string_key( + app.config_path.as_deref(), + "tui", + row_key, + value, + ) { + Ok(path) => CommandResult::message(format!( + "{row_key} = {value} (saved to {})", + path.display() + )), + Err(err) => CommandResult::error(format!("Failed to save: {err}")), + }; + } + return CommandResult::message(format!("{row_key} = {value} (session only)")); + } "stream_chunk_timeout_secs" => { let raw = match value.trim().parse::() { Ok(value) => value, @@ -4822,6 +4872,68 @@ context_window = 262144 )); } + /// The bottom-chrome row presets (#5950) apply on the next frame and + /// `--save` writes the `[tui]` key; an unknown preset names the three. + #[test] + fn config_command_row_presets_apply_live_and_persist_to_tui_table() { + use crate::config::ChromeRowPreset; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-row-presets-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + let config_path = temp_root.join("custom-config.toml"); + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + assert_eq!(app.posture_bar, ChromeRowPreset::Full); + assert_eq!(app.metrics_line, ChromeRowPreset::Full); + + let live = config_command(&mut app, Some("posture_bar compact")); + assert!(!live.is_error, "{live:?}"); + assert_eq!(app.posture_bar, ChromeRowPreset::Compact); + assert_eq!( + live.message.as_deref(), + Some("posture_bar = compact (session only)") + ); + assert_eq!( + config_command(&mut app, Some("posture_bar")) + .message + .as_deref(), + Some("posture_bar = compact") + ); + + let saved = config_command(&mut app, Some("metrics_line HIDDEN --save")); + assert!(!saved.is_error, "{saved:?}"); + assert_eq!(app.metrics_line, ChromeRowPreset::Hidden); + let body = fs::read_to_string(&config_path).unwrap(); + assert!(body.contains("[tui]"), "{body}"); + assert!(body.contains("metrics_line = \"hidden\""), "{body}"); + assert!( + !body.contains("posture_bar"), + "session-only value must not be saved: {body}" + ); + + let bad = config_command(&mut app, Some("metrics_line tiny")); + assert!(bad.is_error); + assert!( + bad.message + .as_deref() + .is_some_and(|m| m.contains("metrics_line must be one of: full, compact, hidden")), + "{bad:?}" + ); + assert_eq!( + app.metrics_line, + ChromeRowPreset::Hidden, + "a bad value changes nothing" + ); + } + #[test] fn config_command_stream_chunk_timeout_rejects_invalid_input() { let _lock = lock_test_env(); diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 700a26566c..b64cd96199 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1811,6 +1811,20 @@ pub struct TuiConfig { /// in `~/.deepseek/config.toml`. #[serde(default, deserialize_with = "deser_status_items")] pub status_items: Option>, + /// How much of the posture bar — the first row under the composer — to + /// paint: `full` (default), `compact`, or `hidden`. `hidden` gives the + /// row back to the transcript; `compact` keeps the row and starts its + /// shed ladder past the clocks, counts and hints (#5950). + /// + /// `status_items` still composes what is *in* the row; this only decides + /// the row's size. Absent from an older `config.toml` means `full`. + #[serde(default)] + pub posture_bar: Option, + /// The same three settings for the metrics line under the posture bar. + /// `compact` keeps the route, the context reading, the cost and the + /// balance and drops the telemetry and the help hint (#5950). + #[serde(default)] + pub metrics_line: Option, /// Ordered list of optional header items the user wants visible. /// /// `None` (the field missing from `config.toml`) preserves the built-in @@ -1860,6 +1874,52 @@ pub struct TuiConfig { pub composer_arrows_scroll: Option, } +/// How much of one bottom-chrome row to paint (#5950). One value for each +/// of the two rows under the composer — [`TuiConfig::posture_bar`] and +/// [`TuiConfig::metrics_line`] — so a small tmux pane can give one or both +/// rows back to the transcript without touching `status_items`. +/// +/// `compact` is not a second renderer: it starts the row's existing shed +/// ladder at a fixed rung and lets width shed the rest, so what it keeps is +/// exactly what a narrow row keeps. +#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ChromeRowPreset { + /// Every fact the row owns, shed only by width. + #[default] + Full, + /// The row's shed ladder started past its most expendable rungs. + Compact, + /// No row: the transcript takes the line. + Hidden, +} + +impl ChromeRowPreset { + /// Every setting value, in the order `/config` names them. + pub const SETTINGS: [&'static str; 3] = ["full", "compact", "hidden"]; + + /// Stable name used in `config.toml` and `/config`. + #[must_use] + pub const fn as_setting(self) -> &'static str { + match self { + Self::Full => "full", + Self::Compact => "compact", + Self::Hidden => "hidden", + } + } + + /// Reverse of [`Self::as_setting`]; `None` for anything else. + #[must_use] + pub fn from_setting(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "full" => Some(Self::Full), + "compact" => Some(Self::Compact), + "hidden" => Some(Self::Hidden), + _ => None, + } + } +} + /// High-level notification trigger override. See /// [`TuiConfig::notification_condition`]. #[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)] diff --git a/crates/tui/src/config/tests.rs b/crates/tui/src/config/tests.rs index 0ff189be73..9b1260ace4 100644 --- a/crates/tui/src/config/tests.rs +++ b/crates/tui/src/config/tests.rs @@ -942,6 +942,52 @@ webhook_token = "secret-token" assert!(absent.base.lifecycle_outbox.is_none()); } +/// `tui.posture_bar` / `tui.metrics_line` (#5950): absent means full — an +/// older `config.toml` keeps loading unchanged — and each key takes one of +/// the three presets. +#[test] +fn tui_config_parses_bottom_chrome_row_presets() { + let raw = r#" +[tui] +posture_bar = "compact" +metrics_line = "hidden" +"#; + let parsed: ConfigFile = toml::from_str(raw).expect("parse row presets"); + let tui = parsed.base.tui.expect("tui table"); + assert_eq!(tui.posture_bar, Some(ChromeRowPreset::Compact)); + assert_eq!(tui.metrics_line, Some(ChromeRowPreset::Hidden)); + + let absent: ConfigFile = toml::from_str("[tui]\nmouse_capture = true\n").expect("old file"); + let tui = absent.base.tui.expect("tui table"); + assert_eq!(tui.posture_bar, None); + assert_eq!(tui.metrics_line, None); + assert_eq!( + tui.posture_bar.unwrap_or_default(), + ChromeRowPreset::Full, + "absent means the full row" + ); + + let bad: Result = toml::from_str("[tui]\nposture_bar = \"tiny\"\n"); + assert!( + bad.is_err(), + "a preset this build does not know is refused, not guessed" + ); + + for (setting, preset) in [ + ("full", ChromeRowPreset::Full), + ("compact", ChromeRowPreset::Compact), + ("hidden", ChromeRowPreset::Hidden), + ] { + assert_eq!(ChromeRowPreset::from_setting(setting), Some(preset)); + assert_eq!( + ChromeRowPreset::from_setting(&setting.to_uppercase()), + Some(preset) + ); + assert_eq!(preset.as_setting(), setting); + } + assert_eq!(ChromeRowPreset::from_setting("tiny"), None); +} + #[test] fn tui_config_parses_control_socket_table() { let raw = r#" diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 7c0b603fc7..007522421a 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -17372,6 +17372,8 @@ api_key = "test-only-key" stream_max_content_mb: None, stream_max_duration_secs: None, status_items: None, + posture_bar: None, + metrics_line: None, osc8_links: None, composer_arrows_scroll: None, notification_condition: None, @@ -17471,6 +17473,8 @@ api_key = "test-only-key" stream_max_content_mb: None, stream_max_duration_secs: None, status_items: None, + posture_bar: None, + metrics_line: None, osc8_links: None, composer_arrows_scroll: None, notification_condition: None, @@ -17508,6 +17512,8 @@ api_key = "test-only-key" stream_max_content_mb: None, stream_max_duration_secs: None, status_items: None, + posture_bar: None, + metrics_line: None, osc8_links: None, composer_arrows_scroll: None, notification_condition: None, @@ -17599,6 +17605,8 @@ api_key = "test-only-key" stream_max_content_mb: None, stream_max_duration_secs: None, status_items: None, + posture_bar: None, + metrics_line: None, osc8_links: None, composer_arrows_scroll: None, notification_condition: None, diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 56dbdd5ac6..9fdd97a5fe 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -2038,6 +2038,16 @@ pub struct App { /// items that painted nothing were retired in #5950 rather than left as /// toggles that lie. pub status_items: Vec, + /// How much of the posture bar to paint (`tui.posture_bar`, #5950): + /// full, compact, or hidden. Sourced from `config.toml` at startup and + /// mutated live by `/config posture_bar`. `hidden` gives the row to the + /// transcript; `compact` starts the bar's shed ladder past the clocks, + /// counts and hints. `status_items` composes the row; this sizes it. + pub posture_bar: crate::config::ChromeRowPreset, + /// The same setting for the metrics line (`tui.metrics_line`, #5950). + /// `compact` keeps the route, context, cost and balance and drops the + /// telemetry and the help hint. + pub metrics_line: crate::config::ChromeRowPreset, /// Optional header items enabled from `tui.header_items` in `config.toml` /// at startup. Built-in header content remains independent of this list. /// Unread since the classic header was superseded by the Tideline info @@ -6219,6 +6229,21 @@ impl App { Self::reasoning_effort_resolution_label(requested, effective, self.api_provider) } + /// The effort label the metrics line's route segment may state: the + /// resolution label when the route can prove an effective tier (or an + /// enabled-but-untiered toggle), `None` when it cannot (#5950). A custom + /// OpenAI-compatible route with no endpoint receipt is the usual `None`; + /// printing `high→effective unavailable` there was a placeholder that + /// could never resolve, so the row omits the field instead. `/status` + /// and the effort cycle message still state the unavailable case in + /// full via [`Self::reasoning_effort_display_label`]. + #[must_use] + pub(crate) fn provable_reasoning_effort_label(&self) -> Option { + (self.effective_reasoning_effort_for_active_route(self.reasoning_effort) + != EffectiveReasoningEffort::Unavailable) + .then(|| self.reasoning_effort_display_label()) + } + /// Return the concrete provider/model route whose current prompt may be /// inspected or replayed. /// diff --git a/crates/tui/src/tui/app/init.rs b/crates/tui/src/tui/app/init.rs index 8af56ed408..9ea246fbe2 100644 --- a/crates/tui/src/tui/app/init.rs +++ b/crates/tui/src/tui/app/init.rs @@ -983,6 +983,16 @@ impl App { .as_ref() .and_then(|tui| tui.status_items.clone()) .unwrap_or_else(crate::config::StatusItem::default_footer), + posture_bar: config + .tui + .as_ref() + .and_then(|tui| tui.posture_bar) + .unwrap_or_default(), + metrics_line: config + .tui + .as_ref() + .and_then(|tui| tui.metrics_line) + .unwrap_or_default(), // Prose wrap cap (`[transcript] prose_measure`, #5436). Resolved // once here so every render pass — main cache and full-screen // overlay — shares one effective width; `None` = full width. diff --git a/crates/tui/src/tui/infoline.rs b/crates/tui/src/tui/infoline.rs index befb84d2ad..0ed082d737 100644 --- a/crates/tui/src/tui/infoline.rs +++ b/crates/tui/src/tui/infoline.rs @@ -34,7 +34,10 @@ //! Which segments exist at all is the user's call: `/statusline` and //! `tui.status_items` compose the row, and [`crate::tui::ui::frame::info_segments`] //! builds only the ones that are on. Shedding decides what survives the -//! width that is left. +//! width that is left. `tui.metrics_line` sizes the row (#5950): `hidden` +//! gives the line back to the transcript, and `compact` starts the shed +//! pass with the telemetry and the help hint already gone +//! ([`InfoLine::compact`]). //! //! Interaction: segment geometry is recorded for parity tests, but only the //! model/route segment and the context reading advertise an action in the @@ -161,6 +164,12 @@ pub struct InfoLine<'a> { /// ASCII-safe / NO_COLOR mode: every glyph goes through /// [`glyphs::ascii_fallback`]. pub ascii_safe: bool, + /// `tui.metrics_line = "compact"` (#5950): the shed pass starts with + /// the telemetry (everything at or above + /// [`InfoSegmentId::SHED_BEFORE_HELP`]) and the help hint already gone, + /// so the row states the route, the context reading, the cost and the + /// balance. Width sheds the rest exactly as it always did. + pub compact: bool, } impl<'a> InfoLine<'a> { @@ -172,6 +181,7 @@ impl<'a> InfoLine<'a> { segments, hovered: None, ascii_safe: false, + compact: false, } } @@ -181,6 +191,12 @@ impl<'a> InfoLine<'a> { self } + #[must_use] + pub fn compact(mut self, compact: bool) -> Self { + self.compact = compact; + self + } + #[must_use] pub fn hovered(mut self, hovered: Option) -> Self { self.hovered = hovered; @@ -223,7 +239,15 @@ fn shed_pass<'t>(info: &'t InfoLine<'_>, area: Rect) -> ShedRow<'t> { let ascii = info.ascii_safe; let help = sym(info.help_hint, ascii); let join_w = sym(ITEM_JOIN, ascii).width(); - let mut kept: Vec<&InfoSegment> = info.segments.iter().collect(); + // A compact row is the full row after its first shed rungs: the + // telemetry and the help hint go before width is consulted. + let mut kept: Vec<&InfoSegment> = info + .segments + .iter() + .filter(|segment| { + !info.compact || segment.id.shed_priority() < InfoSegmentId::SHED_BEFORE_HELP + }) + .collect(); let left_width = |segs: &[&InfoSegment]| -> usize { segs.iter().map(|s| s.rendered_width(ascii)).sum::() + join_w * segs.len().saturating_sub(1) @@ -245,7 +269,7 @@ fn shed_pass<'t>(info: &'t InfoLine<'_>, area: Rect) -> ShedRow<'t> { .map(|(i, _)| i) }; - let mut show_help = !help.is_empty(); + let mut show_help = !help.is_empty() && !info.compact; while total_needed(left_width(&kept), show_help) > area.width as usize { if let Some(pos) = sheddable(&kept, InfoSegmentId::SHED_BEFORE_HELP) { kept.remove(pos); diff --git a/crates/tui/src/tui/infoline/tests.rs b/crates/tui/src/tui/infoline/tests.rs index 6040f2928c..d5420d0585 100644 --- a/crates/tui/src/tui/infoline/tests.rs +++ b/crates/tui/src/tui/infoline/tests.rs @@ -212,6 +212,70 @@ fn infoline_sheds_rate_then_ttft_then_tokens_then_help_then_cost() { } } +/// `tui.metrics_line = "compact"` (#5950) is the row after its first shed +/// rungs, at any width: the telemetry (`tok/s`, `ttft`, `↓ tokens`) and +/// the help hint are gone before width is consulted, the route, the +/// context reading and the cost stay, and the hitboxes follow the same +/// pass so a click still lands on what painted. +#[test] +fn infoline_compact_drops_the_telemetry_and_help_before_width_does() { + let segments = work_segments(); + let hint = help_hint(); + let compact_row = |width: u16| -> (String, Vec) { + let backend = TestBackend::new(width, 1); + let mut terminal = Terminal::new(backend).expect("terminal"); + let mut ids = Vec::new(); + terminal + .draw(|frame| { + let info = InfoLine::new(&UI_THEME, &hint, &segments).compact(true); + ids = infoline_hitboxes(&info, frame.area()) + .into_iter() + .map(|hitbox| hitbox.id) + .collect(); + use ratatui::widgets::Widget; + Widget::render(info, frame.area(), frame.buffer_mut()); + }) + .expect("draw"); + let row = terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol().to_string()) + .collect::(); + (row, ids) + }; + let (wide, ids) = compact_row(160); + assert_eq!( + wide.trim_end(), + "deepseek-v4 · ctx 61% · $0.42", + "compact keeps the route, the reading and the price: {wide:?}" + ); + assert_eq!( + ids, + vec![ + InfoSegmentId::Model, + InfoSegmentId::Context, + InfoSegmentId::Cost + ] + ); + for w in 24..=180u16 { + let (row, _) = compact_row(w); + for gone in ["tok/s", "ttft", "1.2K", "help"] { + assert!( + !row.contains(gone), + "{w}: compact never paints {gone}: {row:?}" + ); + } + assert!( + row.contains("deepseek-v4") && row.contains("ctx 61%"), + "{w}: the floor still never sheds: {row:?}" + ); + } + // The full row at the same width is the row the user had before. + assert!(render_row(&UI_THEME, 160, &segments).contains("tok/s")); +} + /// At the 80% cap the context reading takes the error token — the caller /// picks the ink, and the row paints it on both the label and the value. #[test] diff --git a/crates/tui/src/tui/phase_strip.rs b/crates/tui/src/tui/phase_strip.rs index 308ea9f8e1..c0660b7a5d 100644 --- a/crates/tui/src/tui/phase_strip.rs +++ b/crates/tui/src/tui/phase_strip.rs @@ -54,7 +54,11 @@ pub(crate) fn route_identity_fields( budget: usize, ) -> Option> { let (provider, model) = app.effective_route_identity_display(); - let effort = app.reasoning_effort_display_label(); + // A route that cannot prove its effective tier states no effort field + // rather than `high→effective unavailable` (#5950): a placeholder that + // can never resolve is noise, not a reading. First-party routes keep + // their tier, `auto: tier` and `req→eff` labels. + let effort = app.provable_reasoning_effort_label().unwrap_or_default(); if model.is_empty() { return None; } @@ -586,6 +590,11 @@ pub struct TidelineFooter<'a> { /// the remote-control state. pub right: Option<(&'a str, crate::palette::ChromeInk)>, pub ascii_safe: bool, + /// `tui.posture_bar = "compact"` (#5950): start the shed ladder at + /// [`COMPACT_SHED`] instead of rung 0, so the row states its posture — + /// the permission and mode chips, and the cap warning when it is owed — + /// and nothing live. Width sheds the rest exactly as it always did. + pub compact: bool, } impl<'a> TidelineFooter<'a> { @@ -607,6 +616,7 @@ impl<'a> TidelineFooter<'a> { context_percent: 0, right: None, ascii_safe: false, + compact: false, } } @@ -670,6 +680,18 @@ impl<'a> TidelineFooter<'a> { self } + #[must_use] + pub fn compact(mut self, compact: bool) -> Self { + self.compact = compact; + self + } + + /// The rung the shed ladder starts from: 0 for a full row, past the + /// clocks, hint and counts for a compact one. + fn first_shed_rung(&self) -> u8 { + if self.compact { COMPACT_SHED } else { 0 } + } + fn sym(&self, glyph: &str) -> String { if !self.ascii_safe { return glyph.to_string(); @@ -754,6 +776,11 @@ const SHED_MODE: u8 = 7; const SHED_PERMISSION_KEY: u8 = 8; /// The most-shed rung: everything gone but the permission chip. const MAX_SHED: u8 = SHED_PERMISSION_KEY; +/// Where a compact posture bar (`tui.posture_bar = "compact"`, #5950) +/// starts on the ladder: the clocks, the hint and the counts are gone +/// before width is consulted; the cap warning, the mode chip and the +/// permission chip — the row's posture — stay and shed only by width. +const COMPACT_SHED: u8 = SHED_COUNTS; fn posture_items(footer: &TidelineFooter<'_>, shed: u8) -> Vec { let chip = |text: &str, key: Option<&str>| -> String { @@ -890,7 +917,7 @@ pub fn render_tideline_footer( .map(|(text, _)| text.width() + 1) .unwrap_or(0); let left_budget = width.saturating_sub(right_width); - let items = (0..=MAX_SHED) + let items = (footer.first_shed_rung()..=MAX_SHED) .map(|shed| posture_items(footer, shed)) .find(|items| left_run_width(&mark, items) <= left_budget) .unwrap_or_else(|| posture_items(footer, MAX_SHED)); diff --git a/crates/tui/src/tui/phase_strip/tideline_tests.rs b/crates/tui/src/tui/phase_strip/tideline_tests.rs index 57b352f0c5..97fc731dbc 100644 --- a/crates/tui/src/tui/phase_strip/tideline_tests.rs +++ b/crates/tui/src/tui/phase_strip/tideline_tests.rs @@ -209,6 +209,50 @@ fn posture_bar_sheds_the_clocks_then_the_hint_counts_and_posture_chips() { } } +/// `tui.posture_bar = "compact"` (#5950) starts the ladder past the clocks, +/// the hint and the counts at any width: the row states its posture — the +/// permission and mode chips, and the cap warning when it is owed — and +/// nothing live. Width still sheds from there, and the right slot is +/// untouched. +#[test] +fn compact_posture_bar_states_posture_and_nothing_live() { + let mut fixture = working(); + fixture.right = Some(("/rc connected", ChromeInk::Info)); + let wide = draw(160, 3, &fixture.widget(&UI_THEME).compact(true)); + for kept in ["▶▶ ask (Shift+Tab)", "· work (Tab)", "/rc connected"] { + assert!(wide.contains(kept), "compact keeps {kept}: {wide}"); + } + for gone in [ + "working 1m 15s", + "worked 41m 12s", + "2 agents", + "Esc to interrupt", + ] { + assert!(!wide.contains(gone), "compact drops {gone}: {wide}"); + } + // The full row at the same width is the row the user had before. + assert!(draw(160, 3, &fixture.widget(&UI_THEME)).contains("working 1m 15s")); + + // The cap warning is not a hint: a compact row still says what to do + // about a full context. + fixture.context_percent = 85; + let capped = draw(160, 3, &fixture.widget(&UI_THEME).compact(true)); + assert!(capped.contains("surface soon"), "{capped}"); + assert!(!capped.contains("Esc to interrupt"), "{capped}"); + + for w in 8..=160u16 { + let text = draw(w, 3, &fixture.widget(&UI_THEME).compact(true)); + assert!( + text.contains("ask"), + "{w}: the permission chip never sheds: {text}" + ); + assert!( + !text.contains("working") && !text.contains("agents"), + "{w}: nothing live in a compact row: {text}" + ); + } +} + /// Permission outranks mode when only one posture chip fits: the longest /// mode word must never displace `full access`. #[test] diff --git a/crates/tui/src/tui/ui/frame.rs b/crates/tui/src/tui/ui/frame.rs index 3969023f87..fecc3ce0f2 100644 --- a/crates/tui/src/tui/ui/frame.rs +++ b/crates/tui/src/tui/ui/frame.rs @@ -14,16 +14,27 @@ pub(crate) fn info_context_percent(app: &App) -> u8 { crate::tui::phase_strip::context_percent_from_app(app) } -/// The session cost as the one price string every surface prints -/// (SHELL-DESIGN-20260901 §2.11 item 5): the metrics line, the roster's -/// right column, the price widget and the turn summary all read this. Empty -/// until the session has a priced or counted turn. +/// The session cost as the metrics line prints it — the same price string +/// `/cost`, the roster's right column and the price widget print +/// (SHELL-DESIGN-20260901 §2.11 item 5). Empty until the session has a +/// priced or counted turn. +/// +/// `cost: unknown` (#5578) stays wherever a price *could* exist and this +/// session simply lacks one — a metered route whose model has no price +/// table, a legacy session with unrecorded coverage, turns the pricer could +/// not cover. It is omitted only when the route itself cannot be priced at +/// all ([`BillingPresentation::Unknown`]: a custom OpenAI-compatible +/// endpoint with no pay mode, an unclassified gateway), where the words +/// would be permanent noise rather than a reading (#5950). +/// +/// [`BillingPresentation::Unknown`]: crate::route_billing::BillingPresentation::Unknown pub(crate) fn session_cost_label(app: &App) -> String { + use crate::route_billing::{BillingPresentation, UsageChip}; let usage_chip = app.cumulative_usage_chip(); match &usage_chip { - crate::route_billing::UsageChip::Money(amount) => Some(amount.clone()), - crate::route_billing::UsageChip::PricedSubtotal { .. } - | crate::route_billing::UsageChip::Unknown => { + UsageChip::Money(amount) => Some(amount.clone()), + UsageChip::Unknown if app.billing_presentation == BillingPresentation::Unknown => None, + UsageChip::PricedSubtotal { .. } | UsageChip::Unknown => { crate::route_billing::format_usage_chip(&usage_chip) } _ => None, @@ -335,7 +346,8 @@ fn render_info_row(f: &mut Frame, app: &mut App, area: Rect) -> InfoLineInteract let help_hint = crate::tui::shell_key_routing::info_help_hint(app.ui_locale); let info = InfoLine::new(&app.ui_theme, &help_hint, &segments) .ascii_safe(crate::tui::color_compat::ascii_safe_enabled()) - .hovered(hovered); + .hovered(hovered) + .compact(app.metrics_line == crate::config::ChromeRowPreset::Compact); let hitboxes = infoline_hitboxes(&info, area); let route_area = hitboxes .iter() @@ -1291,7 +1303,10 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<( // it to the bottom (SHELL-DESIGN-20260901 §2.0) so scrolling up reads as // intentional. `keep_header` still governs it in mini mode — the row it // names moved, not the preference. - let info_height = if mini && !mini_cfg.keep_header { + // `tui.metrics_line = "hidden"` gives the row to the transcript (#5950). + let info_height = if (mini && !mini_cfg.keep_header) + || app.metrics_line == crate::config::ChromeRowPreset::Hidden + { 0 } else { info_row_height_for(size.height) @@ -1305,7 +1320,10 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<( // 6+8 collapsed; §5b `Constraint::Length(1)`): phase·cost·posture on the // left, depth·keys on the right. It hides with the rest of the footer // chrome in mini mode, never with the composer. - let footer_height = if mini && !mini_cfg.keep_footer { + // `tui.posture_bar = "hidden"` likewise (#5950). + let footer_height = if (mini && !mini_cfg.keep_footer) + || app.posture_bar == crate::config::ChromeRowPreset::Hidden + { 0 } else { crate::tui::phase_strip::height() @@ -1657,10 +1675,12 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<( if footer_height > 0 { let area = body_chunks[footer_slot]; let facts = crate::tui::phase_strip::tideline_footer_from_app(app, area.width); - let footer = facts.widget( - &app.ui_theme, - crate::tui::color_compat::ascii_safe_enabled(), - ); + let footer = facts + .widget( + &app.ui_theme, + crate::tui::color_compat::ascii_safe_enabled(), + ) + .compact(app.posture_bar == crate::config::ChromeRowPreset::Compact); let buf = f.buffer_mut(); Block::default() .style(Style::default().bg(app.ui_theme.footer_bg)) @@ -2377,6 +2397,109 @@ mod tests { ); } + /// A custom OpenAI-compatible route without an endpoint receipt cannot + /// prove its effective tier. The route segment used to print + /// `high→effective unavailable` — a placeholder that could never resolve + /// (#5950). It now states no effort field at all, while a first-party + /// route keeps its tier label. + #[test] + fn unprovable_effort_states_no_field_instead_of_a_placeholder() { + use crate::tui::phase_strip::{RouteFieldKind, route_identity_fields}; + use crate::tui::underwater::ShellTier; + + let mut app = app_with_context_percent(10); + app.set_provider_identity(crate::config::ApiProvider::Custom, "my-gateway"); + app.auto_model = false; + app.active_route_base_url = "https://gateway.example/v1".to_string(); + app.model = "vendor-model-x".to_string(); + app.reasoning_effort = crate::tui::app::ReasoningEffort::High; + assert_eq!( + app.reasoning_effort_display_label(), + "high→effective unavailable", + "the full label still tells /status the truth" + ); + assert_eq!(app.provable_reasoning_effort_label(), None); + let fields = route_identity_fields(&app, ShellTier::Wide, 200).expect("route fields"); + assert!( + fields + .iter() + .all(|field| field.kind != RouteFieldKind::Effort), + "no effort field on an unprovable route: {fields:?}" + ); + let row = metrics_row(&app, 200); + assert!(row.contains("vendor-model-x"), "{row:?}"); + assert!(!row.contains("unavailable"), "{row:?}"); + assert!(!row.contains("high"), "{row:?}"); + + // First-party routes are unchanged: the tier label stays. + let app = app_with_context_percent(10); + let label = app + .provable_reasoning_effort_label() + .expect("a first-party route proves its tier"); + assert_eq!(label, app.reasoning_effort_display_label()); + let fields = route_identity_fields(&app, ShellTier::Wide, 200).expect("route fields"); + assert!( + fields + .iter() + .any(|field| field.kind == RouteFieldKind::Effort && field.text == label), + "{fields:?}" + ); + } + + /// `cost: unknown` (#5578) stays wherever a price could exist — a + /// metered route whose coverage this session lacks — and is omitted + /// only where nothing about the route can be priced (#5950). + #[test] + fn cost_is_omitted_only_where_the_route_cannot_be_priced() { + use crate::route_billing::BillingPresentation; + let mut app = app_with_context_percent(10); + app.session.cost_coverage_unknown_legacy = true; + + app.billing_presentation = BillingPresentation::Metered; + assert!(matches!( + app.cumulative_usage_chip(), + crate::route_billing::UsageChip::Unknown + )); + assert_eq!(super::session_cost_label(&app), "cost: unknown"); + let row = metrics_row(&app, 200); + assert!( + row.contains("cost: unknown"), + "a priceable route keeps the honesty: {row:?}" + ); + + app.billing_presentation = BillingPresentation::Unknown; + assert!(matches!( + app.cumulative_usage_chip(), + crate::route_billing::UsageChip::Unknown + )); + assert_eq!(super::session_cost_label(&app), ""); + let ids: Vec = super::info_segments(&app, 200) + .iter() + .map(|segment| segment.id) + .collect(); + assert!(!ids.contains(&InfoSegmentId::Cost), "{ids:?}"); + let row = metrics_row(&app, 200); + assert!( + !row.contains("cost"), + "an unpriceable route states no price: {row:?}" + ); + assert!(row.contains("ctx 10%"), "and nothing else moves: {row:?}"); + + // A real price on an otherwise unclassified route still prints. + app.session.cost_coverage_unknown_legacy = false; + app.session.cost_priced_turns = 1; + app.session.session_cost = 0.42; + assert!( + matches!( + app.cumulative_usage_chip(), + crate::route_billing::UsageChip::Money(_) + ), + "{:?}", + app.cumulative_usage_chip() + ); + assert!(!super::session_cost_label(&app).is_empty()); + } + /// Every remaining status item owns a segment, and an empty list leaves /// the row with nothing but the help hint — no toggle in `/statusline` /// paints something no toggle can remove. diff --git a/crates/tui/src/tui/ui/frame/one_owner_tests.rs b/crates/tui/src/tui/ui/frame/one_owner_tests.rs index e490ca2130..f3f4f1efd1 100644 --- a/crates/tui/src/tui/ui/frame/one_owner_tests.rs +++ b/crates/tui/src/tui/ui/frame/one_owner_tests.rs @@ -299,3 +299,104 @@ fn double_tap_window_shows_the_send_now_hint() { ); assert!(!rows[composer].contains("Esc to interrupt")); } + +/// `tui.posture_bar` / `tui.metrics_line` (#5950): `hidden` gives a row +/// back to the transcript — one row per hidden preset, two for both — and +/// `compact` keeps the row with its first shed rungs already gone. Every +/// other row of the frame stays where it was, so the composer is never +/// displaced by the choice. +#[test] +fn row_presets_reclaim_rows_and_quiet_them_in_the_composed_frame() { + use crate::config::ChromeRowPreset; + // 160 columns joins the blocker sizes above: the working clock's two + // halves only both fit beside the pinned unenforced-scope permission + // chip from that width up, and this test asserts the full row's clocks. + let (width, height) = (160u16, 32u16); + let posture_row = |rows: &[String]| rows.iter().position(|row| row.contains("▶▶")); + let metrics_row = |rows: &[String]| rows.iter().position(|row| row.contains("ctx ")); + + let mut app = working_app(); + let full = draw(&mut app, width, height); + let posture = posture_row(&full).expect("full frame paints the posture bar"); + let metrics = metrics_row(&full).expect("full frame paints the metrics line"); + assert_eq!( + metrics, + posture + 1, + "the metrics line sits under the posture bar" + ); + // The full row's live facts: a turn clock (this fixture waits on + // sub-agents, so #5914 words it `sub-agents underway` rather than + // `working`), the live counts and the hint — the three compact drops. + assert!(full[posture].contains("1m 15s"), "{:?}", full[posture]); + assert!(full[posture].contains("2 agents"), "{:?}", full[posture]); + assert!( + full[posture].contains("Esc to interrupt"), + "{:?}", + full[posture] + ); + assert!(full[metrics].contains("tok/s"), "{:?}", full[metrics]); + + // Hide the posture bar: the metrics line takes its row, and the + // transcript above gains one. + app.posture_bar = ChromeRowPreset::Hidden; + let rows = draw(&mut app, width, height); + assert_eq!(posture_row(&rows), None, "no posture bar: {rows:#?}"); + assert_eq!( + metrics_row(&rows), + Some(metrics), + "the metrics line keeps its row" + ); + assert_eq!( + count_rows_containing(&rows, "ctx "), + 1, + "the context reading is still painted once" + ); + + // Hide both: two rows reclaimed. + app.metrics_line = ChromeRowPreset::Hidden; + let rows = draw(&mut app, width, height); + assert_eq!(posture_row(&rows), None); + assert_eq!(metrics_row(&rows), None); + assert_eq!(count_rows_containing(&rows, "deepseek-v4-pro"), 0); + + // Compact both: the rows are back, quieter — the posture and the + // route/reading/price, none of the live facts or telemetry. + app.posture_bar = ChromeRowPreset::Compact; + app.metrics_line = ChromeRowPreset::Compact; + let rows = draw(&mut app, width, height); + let posture = posture_row(&rows).expect("compact paints the posture bar"); + let metrics = metrics_row(&rows).expect("compact paints the metrics line"); + assert_eq!(metrics, posture + 1); + let (mode, permission) = crate::tui::underwater::posture_chips(&app); + assert!(rows[posture].contains(permission.expect("permission chip").0.as_ref())); + assert!(rows[posture].contains(mode.expect("mode chip").0.as_ref())); + for gone in ["working", "2 agents", "Esc to interrupt"] { + assert!( + !rows[posture].contains(gone), + "{gone} in {:?}", + rows[posture] + ); + } + assert!( + rows[metrics].contains("deepseek-v4-pro"), + "{:?}", + rows[metrics] + ); + let pct = super::info_context_percent(&app); + assert!( + rows[metrics].contains(&format!("ctx {pct}%")), + "{:?}", + rows[metrics] + ); + for gone in [ + "tok/s", + "ttft", + crate::tui::shell_key_routing::info_help_hint(app.ui_locale).as_str(), + ] { + assert!( + !rows[metrics].contains(gone), + "{gone} in {:?}", + rows[metrics] + ); + } +} diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 507e740d47..9527d5a5b9 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -7678,6 +7678,8 @@ fn terminal_probe_timeout_uses_tui_config_and_clamps() { stream_max_content_mb: None, stream_max_duration_secs: None, status_items: None, + posture_bar: None, + metrics_line: None, header_items: None, osc8_links: None, notification_condition: None, diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 9f5904f421..f436146274 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -238,6 +238,17 @@ The keys `status`, `agents`, `reasoning_replay`, `prefix_stability`, they drove nothing. Old configuration files still load — the retired keys are ignored with a warning in the log. +`status_items` composes the rows; two size presets decide how much of each +row paints. `[tui].posture_bar` and `[tui].metrics_line` each take `full` +(the default), `compact`, or `hidden`, also settable at runtime with +`/config posture_bar compact`. `compact` is the row after its first shed +rungs: the posture bar keeps its permission and mode chips — and the cap +warning, which is advice, not decoration — and drops the clocks, counts and +hint; the metrics line keeps the route, the context reading, the cost and +the balance, and drops the telemetry and the help hint. `hidden` gives the +row back to the transcript. A small tmux pane can hide both rows without +touching what `/statusline` composes. + `session_metrics` (on by default) paints the latency pair on the metrics line: `ttft 1.5s` — the mean time to first streamed token — and `120 tok/s`, provider-reported output tokens over streamed seconds. Both come from the diff --git a/docs/zh_hans/GUIDE.md b/docs/zh_hans/GUIDE.md index cdd93d8c58..c2b1fab0be 100644 --- a/docs/zh_hans/GUIDE.md +++ b/docs/zh_hans/GUIDE.md @@ -176,6 +176,8 @@ JSON 把凭据的 `source`(来源)与字面的 `availability`(可用性) `status`、`agents`、`reasoning_replay`、`prefix_stability`、`git_branch`、`last_tool_elapsed` 和 `rate_limit` 这些键在 0.9.13 中已退役:它们不驱动任何东西。旧的配置文件仍可加载——已退役的键会被忽略并在日志中给出警告。 +`status_items` 负责组合这两行;另有两个尺寸预设决定每行绘制多少。`[tui].posture_bar` 和 `[tui].metrics_line` 各接受 `full`(默认)、`compact` 或 `hidden`,也可以在运行时用 `/config posture_bar compact` 设置。`compact` 是该行走完最初几级舍弃阶梯后的样子:姿态栏保留权限与模式片区——以及属于建议而非装饰的容量警示——并舍弃时钟、计数和提示;指标行保留路由、上下文读数、成本和余额,并舍弃遥测与帮助提示。`hidden` 把该行交还给转录区。狭小的 tmux 面板可以隐藏两行而不动 `/statusline` 的组合。 + `session_metrics`(默认开启)在指标行上绘制这一对延迟读数:`ttft 1.5s`(到首个流式 token 的平均时间)和 `120 tok/s`(提供商报告的输出 token 除以流式秒数)。两者来自 `/status` 完整打印的同一批累加器(turns、steps、LLM 与工具墙钟时间、缓存命中、输入);提供商或运行时证据尚未到达的数字会被省略而不是估算。在窄行上,这一对会先于成本和上下文读数被舍弃,而不是截断某个数字。 转录区(对话记录)就是审计轨迹。当 Codewhale 读文件、跑命令或改代码时,动作会出现在那里。如果某条命令失败,把可见的失败输出作为你下一条指令的一部分,而不是从头再来。