fix(tui): give the status rail a grammar instead of a run-on sentence

Captured at 120x32 the bottom rail read:

  ▌· idle · Ollama · deepseek-v4-flash · max · Anonymous usage counts are
  on. …  ⌥V:output · /context:context · fn+F1:keys

Four different kinds of fact — live state, route identity, a telemetry
consent notice, and keyboard hints — strung together at identical weight
with identical separators. Nothing was grouped, so nothing could be
skimmed. At 80 columns the row stopped mid-notice; at 60 it overflowed
the terminal and was clipped mid-word with no mark at all:

  ▌· idle · Ollama · deepseek-v4-flash · max · Anonymous usage

Three changes, all inside phase_strip.rs.

Group by meaning. A middle dot separates peers *inside* a group; a blank
gutter of the same three columns separates groups. The model name — the
field a mid-session glance is actually hunting for — reads one step
brighter (MetadataValue) than the qualifiers that narrow it (Metadata),
and `Esc to interrupt` moved from the separator weight to the hint
weight it shares with the right-hand chords. No new colours; every ink
still resolves through ChromeInk.

Never truncate. `route_identity_label` used to compose the full label
and then truncate_to_width it to a fixed 24/44/64 columns, which
rendered `deepseek-v4-flash-prev…` — worse than nothing, because routes
share prefixes. Identity now sheds the provider, then the effort, and
finally itself. A notice sheds whole sentences; a one-sentence notice
too long for the row sheds at its inner joints with the trailing `:`/`;`
cut, because a phrase ending on a colon advertises more-is-coming just
as loudly as an ellipsis does. That fallback is not theoretical: the
first draft dropped `Auto-denied exec_shell: denied earlier; restart
Codewhale` entirely at 60 and 80 columns, which is how it was found.
Clauses rejoin without a Latin space after a full-width stop, so the
Japanese receipt reads as Japanese.

Earn the row. A notice is transient and identity is standing, so while a
notice is live the identity and ledger groups stand down and the notice
takes their columns; the key hints yield last and only if the notice
needs them. And the rail stopped advertising `/context:context` — it was
paying eighteen columns of a 24-row screen to name a slash command that
announces itself the moment you type `/`. The rail advertises chords you
cannot discover any other way, and now reads the same at 80 as at 200.

After, same captures:

  120  ▌· idle   Anonymous usage counts are on. Conversations and code
       are never collected.              ⌥V:output · fn+F1:keys
  120  ▌› draft   Ollama · deepseek-v4-flash · max
                                         ⌥V:output · fn+F1:keys
   80  ▌· idle   Anonymous usage counts are on.
                             ⌥V:output · fn+F1:keys
   60  ▌› draft   deepseek-v4-flash · max    ⌥V:output · fn+F1:keys

At 60 the rail sheds the provider so the key hints survive; before, the
hints were silently dropped at that width and the row was clipped.

cargo test -p codewhale-tui --lib: 10872 passed, 0 failed.
RUSTFLAGS=-Dwarnings cargo clippy --lib --all-targets: clean.
/tmp/version-drift-gate.sh: ALL PASS.

Implemented with agent assistance.
Signed-off-by: Hunter Bown <[email protected]>
This commit is contained in:
Hunter Bown committed 2026-08-20 12:19:48 -07:00
1 parent 4c3a60ede5
commit d74f159f92
3 files changed
+639 -178

No files matched your search

+32
View File
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- The bottom status rail is no longer one run-on sentence. At 120 columns it
read `▌· idle · Ollama · deepseek-v4-flash · max · Anonymous usage counts are
on. … ⌥V:output · /context:context · fn+F1:keys` — live state, route
identity, a telemetry consent notice, and keyboard hints all strung together
by the same middle dot in the same ink, so nothing was grouped and the eye
had nothing to skim by. At 80 columns it simply stopped mid-notice, and at 60
the row overflowed and was clipped by the terminal mid-word. The rail now
divides its groups with a blank gutter instead of another dot (the dot is
kept for peers *inside* a group), the model name reads one step brighter than
the qualifiers that narrow it, and `Esc to interrupt` reads in the same hint
weight as the right-hand chords rather than in the separator weight.
- Nothing on the status rail is ever truncated now. A notice sheds whole
sentences to fit, and if one sentence is still too long it sheds at the inner
joints — a colon, a semicolon — with the trailing mark cut so the phrase that
survives does not itself advertise that more was coming. Route identity sheds
the provider, then the reasoning effort, rather than rendering
`deepseek-v4-flash-prev…`; a clipped model name is worse than no model name
because routes share prefixes. Clauses rejoin without a Latin space after a
full-width stop, so the Japanese receipt reads as Japanese.
- A notice now stands the standing facts down instead of queueing behind them.
Route identity and the ledger chips are still there in ten seconds; the
notice is not, so it takes the row and the key hints yield last. This is what
makes the telemetry receipt readable at 80 columns, where it used to be
simultaneously always present and never legible.
- The status rail no longer advertises `/context:context`. It was spending
eighteen columns of a 24-row screen to name a slash command that announces
itself the moment you type `/`; the rail advertises chords you cannot
discover any other way. The rail now reads the same at 80 columns as at 200.
- The idle screen no longer has an absolute path stretched across it. The
workspace caption between the wordmark and "What do you want to accomplish?"
was composed at full length and then truncated to the lane width, which made
+32
View File
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
- The bottom status rail is no longer one run-on sentence. At 120 columns it
read `▌· idle · Ollama · deepseek-v4-flash · max · Anonymous usage counts are
on. … ⌥V:output · /context:context · fn+F1:keys` — live state, route
identity, a telemetry consent notice, and keyboard hints all strung together
by the same middle dot in the same ink, so nothing was grouped and the eye
had nothing to skim by. At 80 columns it simply stopped mid-notice, and at 60
the row overflowed and was clipped by the terminal mid-word. The rail now
divides its groups with a blank gutter instead of another dot (the dot is
kept for peers *inside* a group), the model name reads one step brighter than
the qualifiers that narrow it, and `Esc to interrupt` reads in the same hint
weight as the right-hand chords rather than in the separator weight.
- Nothing on the status rail is ever truncated now. A notice sheds whole
sentences to fit, and if one sentence is still too long it sheds at the inner
joints — a colon, a semicolon — with the trailing mark cut so the phrase that
survives does not itself advertise that more was coming. Route identity sheds
the provider, then the reasoning effort, rather than rendering
`deepseek-v4-flash-prev…`; a clipped model name is worse than no model name
because routes share prefixes. Clauses rejoin without a Latin space after a
full-width stop, so the Japanese receipt reads as Japanese.
- A notice now stands the standing facts down instead of queueing behind them.
Route identity and the ledger chips are still there in ten seconds; the
notice is not, so it takes the row and the key hints yield last. This is what
makes the telemetry receipt readable at 80 columns, where it used to be
simultaneously always present and never legible.
- The status rail no longer advertises `/context:context`. It was spending
eighteen columns of a 24-row screen to name a slash command that announces
itself the moment you type `/`; the rail advertises chords you cannot
discover any other way. The rail now reads the same at 80 columns as at 200.
- The idle screen no longer has an absolute path stretched across it. The
workspace caption between the wordmark and "What do you want to accomplish?"
was composed at full length and then truncated to the lane width, which made
+575 -178
View File
@@ -10,7 +10,6 @@
//! shim note at `crates/tui/src/tui/ocean.rs:35` — so there is no
//! footer-below-composer fallback path left.
use crate::localization::truncate_to_width;
use std::borrow::Cow;
use ratatui::{
@@ -23,7 +22,7 @@ use ratatui::{
use unicode_width::UnicodeWidthStr;
use crate::localization::{MessageId, tr};
use crate::palette::ChromeInk;
use crate::palette::{ChromeInk, UiTheme};
use crate::tui::{
app::App,
underwater::{LiveActivity, ShellPhase, ShellTier, phase_marker_with_activity},
@@ -100,24 +99,151 @@ fn session_cache_hit_percentage(app: &App) -> Option<u8> {
Some(((hit * 100 + total / 2) / total) as u8)
}
/// Quiet route identity for the phase rail. The header owns posture and
/// workspace truth; this secondary line keeps the active provider, model, and
/// reasoning choice available without making them the first thing a user sees.
fn route_identity_label(app: &App, tier: ShellTier) -> String {
/// Route identity for the rail, shed field by field until it fits `budget`.
///
/// The old version composed the full `provider · model · effort` label and
/// then `truncate_to_width`'d it to a fixed 24/44/64 columns, which happily
/// rendered `deepseek-v4-flash-prev…`. A clipped model name is worse than no
/// model name: routes share prefixes, so the ellipsis is the rail admitting
/// it will not tell you which model is answering. Shed the qualifiers
/// instead — provider first, then effort — and if the bare model name still
/// does not fit, shed the whole group. `/model` and `/status` own the full
/// route either way.
fn route_identity_fields(app: &App, tier: ShellTier, budget: usize) -> Option<Vec<String>> {
let (provider, model) = app.effective_route_identity_display();
let effort = app.reasoning_effort_display_label();
let label = match tier {
// On the smallest shell, model + effort are more useful than repeating
// the provider. The full route remains available in /model and /status.
ShellTier::Compact => format!("{model} · {effort}"),
ShellTier::Normal | ShellTier::Wide => format!("{provider} · {model} · {effort}"),
};
let budget = match tier {
ShellTier::Compact => 24,
ShellTier::Normal => 44,
ShellTier::Wide => 64,
};
truncate_to_width(&label, budget)
if model.is_empty() {
return None;
}
let mut candidates: Vec<Vec<String>> = Vec::new();
if tier != ShellTier::Compact && !provider.is_empty() && !effort.is_empty() {
// The smallest shell never repeats the provider: model and effort are
// the two facts that change what comes back.
candidates.push(vec![provider, model.clone(), effort.clone()]);
}
if !effort.is_empty() {
candidates.push(vec![model.clone(), effort]);
}
candidates.push(vec![model]);
candidates.into_iter().find(|fields| {
let width = fields.iter().map(|field| field.width()).sum::<usize>()
+ fields.len().saturating_sub(1) * ITEM_SEPARATOR_WIDTH;
width <= budget
})
}
/// Paint an identity group: the model name one step brighter than the
/// qualifiers that narrow it, so the field a mid-session glance is looking
/// for is the field the eye lands on. Two weights, no new colour.
fn identity_spans(fields: &[String], model_index: usize, theme: &UiTheme) -> Vec<Span<'static>> {
let mut spans = Vec::with_capacity(fields.len() * 2);
for (index, field) in fields.iter().enumerate() {
if index > 0 {
spans.push(Span::styled(
ITEM_SEPARATOR,
Style::default().fg(ChromeInk::MetadataDim.color(theme)),
));
}
let ink = if index == model_index {
ChromeInk::MetadataValue
} else {
ChromeInk::Metadata
};
spans.push(Span::styled(
field.clone(),
Style::default().fg(ink.color(theme)),
));
}
spans
}
/// Split a notice at its joints, coarsest first.
///
/// A rail notice is prose, and prose has joints. Cutting at a joint keeps
/// every word that survives true; cutting mid-phrase and hanging an ellipsis
/// off the end only advertises that the row lost the argument. Sentence stops
/// are the joint we want; the inner marks are the fallback for a one-sentence
/// notice that is still too long for a narrow rail — losing the second half
/// of `Auto-denied exec_shell: denied earlier` beats losing the warning.
fn notice_clauses<'a>(text: &'a str, marks: &[char]) -> Vec<&'a str> {
let mut clauses = Vec::new();
let mut start = 0usize;
let mut chars = text.char_indices().peekable();
while let Some((idx, ch)) = chars.next() {
if !marks.contains(&ch) {
continue;
}
// Full-width marks carry no trailing space, so they break on sight.
// ASCII marks only break before whitespace, which keeps `0.9.11`,
// `docs/TELEMETRY.md`, and `https://…` in one piece.
let breaks = !ch.is_ascii() || chars.peek().is_none_or(|(_, next)| next.is_whitespace());
if !breaks {
continue;
}
let end = idx + ch.len_utf8();
let clause = text[start..end].trim();
if !clause.is_empty() {
clauses.push(clause);
}
start = end;
}
let rest = text[start..].trim();
if !rest.is_empty() {
clauses.push(rest);
}
clauses
}
/// Sentence stops — the joint a notice prefers to be cut at.
const SENTENCE_MARKS: [char; 7] = ['.', '!', '?', '…', '。', '!', '?'];
/// Inner joints, used only when one sentence still will not fit the rail.
const CLAUSE_MARKS: [char; 8] = [';', ':', ',', '—', ';', ':', ',', '、'];
fn join_while_fitting(clauses: &[&str], budget: usize) -> Option<String> {
let mut fitted = String::new();
for clause in clauses {
// A full-width stop already carries its own breathing room; putting
// a Latin space after `。` is a typographic accent in the wrong
// language.
let space = usize::from(!fitted.is_empty() && fitted.ends_with(|ch: char| ch.is_ascii()));
let candidate = fitted.width() + space + clause.width();
if candidate > budget {
break;
}
if space == 1 {
fitted.push(' ');
}
fitted.push_str(clause);
}
// A phrase that ends on `:` or `;` is still telling you more is coming —
// the same lie an ellipsis tells. Cut the mark and let the phrase stand.
let fitted = fitted
.trim_end_matches(|ch| CLAUSE_MARKS.contains(&ch) || ch == ' ')
.to_string();
(!fitted.is_empty()).then_some(fitted)
}
/// Fit a notice into `budget` by dropping whole trailing clauses.
///
/// Returns `None` only when not even the first inner phrase fits, and the
/// rail then says nothing rather than dangling a stump. Notices get first
/// call on the row: identity and the ledger chips have already stood down by
/// the time this is asked, and the key hints stand down after it if that is
/// what the notice needs.
fn fit_notice(text: &str, budget: usize) -> Option<String> {
let text = text.trim();
if text.is_empty() || budget == 0 {
return None;
}
if text.width() <= budget {
return Some(text.to_string());
}
let sentences = notice_clauses(text, &SENTENCE_MARKS);
if let Some(fitted) = join_while_fitting(&sentences, budget) {
return Some(fitted);
}
let first = sentences.first().copied().unwrap_or(text);
join_while_fitting(&notice_clauses(first, &CLAUSE_MARKS), budget)
}
/// Toasts share the footer rail, so their typed level must resolve through
@@ -131,9 +257,21 @@ fn status_toast_ink(level: crate::tui::app::StatusToastLevel) -> ChromeInk {
}
}
/// Paint the one-line phase rail. Compact left marker (icon + verb + duration)
/// instead of a full-width routine phase band. Amber only for approval/waiting;
/// cyan/teal for routine work.
/// Paint the one-line phase rail.
///
/// The rail speaks four kinds of fact and they are not peers:
///
/// * **live state** — the phase marker, in the phase accent. Always present.
/// * **a notice** — a transient thing the session is telling you. When one is
/// live it takes the identity slot and the standing facts stand down; they
/// are back in a few seconds and the notice is not.
/// * **route identity** — provider, model, effort. Standing background truth.
/// * **key hints** — right-aligned, dimmest, the only group that is optional
/// twice over.
///
/// Groups are divided by a blank gutter and by ink family; peers *inside* a
/// group keep the ` · `. Nothing on the rail is ever truncated: every group
/// either fits whole or sheds a whole field.
pub fn render(area: Rect, buf: &mut Buffer, app: &mut App) {
if area.width == 0 || area.height == 0 {
return;
@@ -167,7 +305,7 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &mut App) {
if tier != ShellTier::Compact && matches!(phase, ShellPhase::Working | ShellPhase::Verifying) {
if let Some(detail) = working_detail(app, activity) {
left.push(Span::styled(
" · ",
ITEM_SEPARATOR,
Style::default().fg(ChromeInk::MetadataDim.color(&app.ui_theme)),
));
left.push(Span::styled(
@@ -176,67 +314,15 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &mut App) {
));
}
left.push(Span::styled(
format!(
" · {}",
tr(app.ui_locale, MessageId::FooterHintEscInterrupt)
),
ITEM_SEPARATOR,
Style::default().fg(ChromeInk::MetadataDim.color(&app.ui_theme)),
));
}
let route_label = route_identity_label(app, tier);
if !route_label.is_empty() {
// `Esc to interrupt` is a key hint that happens to sit next to the
// thing it interrupts. It reads in the hint weight the right-hand
// chords use, not in the separator weight it used to share.
left.push(Span::styled(
" · ",
Style::default().fg(ChromeInk::MetadataDim.color(&app.ui_theme)),
));
left.push(Span::styled(
route_label,
Style::default().fg(ChromeInk::Metadata.color(&app.ui_theme)),
));
}
// The ledger chips are built before the toast so the toast can be given
// whatever width is genuinely left over. They are appended after it, so
// the visual order is unchanged.
let mut tail: Vec<Span<'static>> = Vec::new();
let chip = app.cumulative_usage_chip();
if tier != ShellTier::Compact
&& let Some(amount) = match &chip {
crate::route_billing::UsageChip::Money(amount) => Some(amount.clone()),
crate::route_billing::UsageChip::PricedSubtotal { .. } => {
crate::route_billing::format_usage_chip(&chip)
}
_ => None,
}
{
tail.push(Span::styled(
" · ",
Style::default().fg(ChromeInk::MetadataDim.color(&app.ui_theme)),
));
tail.push(Span::styled(
amount,
Style::default().fg(ChromeInk::Metadata.color(&app.ui_theme)),
));
}
// The session metrics strip owns the cache cell when it is on; the
// standalone `cache N%` chip stays for users who turned the strip off.
let metrics_enabled = app
.status_items
.contains(&crate::config::StatusItem::SessionMetrics);
if !metrics_enabled
&& tier != ShellTier::Compact
&& app.status_items.contains(&crate::config::StatusItem::Cache)
&& let Some(pct) = session_cache_hit_percentage(app)
{
tail.push(Span::styled(
" · ",
Style::default().fg(ChromeInk::MetadataDim.color(&app.ui_theme)),
));
tail.push(Span::styled(
format!("cache {pct}%"),
Style::default().fg(ChromeInk::Metadata.color(&app.ui_theme)),
tr(app.ui_locale, MessageId::FooterHintEscInterrupt).into_owned(),
Style::default().fg(ChromeInk::MetadataHint.color(&app.ui_theme)),
));
}
@@ -244,27 +330,28 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &mut App) {
// the ledger. Idle/typing may advertise keys on the quiet footer.
// Hints come from shell_key_routing so advertised chords match handlers;
// bare letters are never advertised — the composer owns printable keys.
let right_text: Cow<'static, str> = if PhaseStripPlacement::for_phase(phase).is_above_composer()
{
Cow::Borrowed("")
} else {
use crate::tui::shell_key_routing::{ShellBindingId, binding, footer_action_hints};
let hint_keys = tr(app.ui_locale, MessageId::FooterHintKeys);
let hint_output = tr(app.ui_locale, MessageId::FooterHintOutput);
let hint_context = tr(app.ui_locale, MessageId::FooterHintContext);
Cow::Owned(match tier {
ShellTier::Compact => {
format!("{}:{hint_keys}", binding(ShellBindingId::Help).footer_chord)
}
ShellTier::Normal => footer_action_hints(false)
.replace("{output}", hint_output.as_ref())
.replace("{keys}", hint_keys.as_ref()),
ShellTier::Wide => footer_action_hints(true)
.replace("{output}", hint_output.as_ref())
.replace("{context}", hint_context.as_ref())
.replace("{keys}", hint_keys.as_ref()),
})
};
let mut right_text: Cow<'static, str> =
if PhaseStripPlacement::for_phase(phase).is_above_composer() {
Cow::Borrowed("")
} else {
use crate::tui::shell_key_routing::{ShellBindingId, binding, footer_action_hints};
let hint_keys = tr(app.ui_locale, MessageId::FooterHintKeys);
let hint_output = tr(app.ui_locale, MessageId::FooterHintOutput);
Cow::Owned(match tier {
ShellTier::Compact => {
format!("{}:{hint_keys}", binding(ShellBindingId::Help).footer_chord)
}
// Wide used to add `/context:context`. The rail advertises
// chords you cannot discover any other way; a slash command
// announces itself the moment you type `/`, so it was paying
// eighteen columns of a 24-row screen to tell you something
// the composer already tells you. The rail now reads the same
// at 80 columns as at 200.
ShellTier::Normal | ShellTier::Wide => footer_action_hints(false)
.replace("{output}", hint_output.as_ref())
.replace("{keys}", hint_keys.as_ref()),
})
};
// `← for agents · ↓ to manage`: advertise only while the empty
// composer owns those keys and a worker exists. Focused surfaces, modals,
@@ -275,56 +362,29 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &mut App) {
// post-completion menu ownership into the same predicate.
&& crate::tui::agent_focus::shell_shortcuts_available(app, false))
.then(|| crate::tui::agent_focus::footer_agent_hints(app));
let right_text: Cow<'static, str> = match agent_hints {
Some(hints) if !right_text.is_empty() => Cow::Owned(format!("{hints} · {right_text}")),
right_text = match agent_hints {
Some(hints) if !right_text.is_empty() => {
Cow::Owned(format!("{hints}{ITEM_SEPARATOR}{right_text}"))
}
// A settled turn keeps the strip above the composer without the key
// chorus; the two agent keys still apply there, so they stay visible.
Some(hints) if phase == ShellPhase::Done => Cow::Owned(hints),
_ => right_text,
};
let right_width = right_text.width();
let available = usize::from(area.width);
let phase_width = span_width(&left);
let hint_width = right_text.width();
let hint_cost = if hint_width == 0 {
0
} else {
hint_width + HINT_GAP
};
// Session metrics strip (`4 turns · 108 steps │ LLM 11m46s · tools 1m52s
// │ TTFT 1.5s · 120 tok/s │ cache 99% │ in 9.3M`). It takes whatever
// columns are genuinely free after the phase marker, the ledger chips, a
// floor for any live toast, and the key hints, and sheds its
// lowest-value groups to fit rather than truncating a number.
if metrics_enabled && tier != ShellTier::Compact {
let snapshot = crate::tui::session_metrics::snapshot_from_app(app);
if !snapshot.is_empty() {
let toast_reserve = status_toast
.as_ref()
.filter(|toast| !toast.text.trim().is_empty())
.map(|toast| toast.text.trim().width().min(TOAST_MIN_WIDTH) + TOAST_SEPARATOR_WIDTH)
.unwrap_or(0);
let budget = available.saturating_sub(
span_width(&left)
+ span_width(&tail)
+ toast_reserve
+ right_width
+ TOAST_RIGHT_GAP
+ METRICS_SEPARATOR_WIDTH,
);
let ascii = crate::tui::color_compat::ascii_safe_enabled();
let strip = crate::tui::session_metrics::fit_to_width(
crate::tui::session_metrics::build_groups(snapshot, app.ui_locale),
budget,
crate::tui::session_metrics::Separators::for_ascii(ascii),
);
if !strip.is_empty() {
tail.push(Span::styled(
if ascii { " | " } else { " │ " },
Style::default().fg(ChromeInk::MetadataDim.color(&app.ui_theme)),
));
tail.extend(crate::tui::session_metrics::spans(&strip, &app.ui_theme));
}
}
}
if tier != ShellTier::Compact
&& let Some(toast) = status_toast.filter(|toast| {
let notice = (tier != ShellTier::Compact)
.then_some(status_toast)
.flatten()
.filter(|toast| {
// Completion may land in the same event drain as an approval
// denial. Keep unresolved attention/error receipts visible after
// `done`; only routine informational completion copy yields to the
@@ -338,35 +398,128 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &mut App) {
&& !toast.text.trim().is_empty()
&& toast.text.trim() != phase_label.as_ref()
})
{
// The budget used to be a flat 40 columns no matter how wide the
// terminal was, which cut a warning whose entire job is to explain an
// unexpected state down to `Delegated coordination unavailable — an…`.
// Spend the row that actually exists: everything left after the phase
// marker, the ledger chips, the key hints, and a gap between them.
let toast_budget = available
.saturating_sub(
span_width(&left)
+ TOAST_SEPARATOR_WIDTH
+ span_width(&tail)
+ right_width
+ TOAST_RIGHT_GAP,
)
.max(TOAST_MIN_WIDTH);
.and_then(|toast| {
// Fit the notice against the whole row it could have, then keep
// the key hints only if they cost the notice nothing. Standing
// metadata never competes — it stood down before we got here —
// and the hints are the one group that is optional twice over.
let alone = available.saturating_sub(phase_width + GROUP_GAP_WIDTH);
let text = fit_notice(&toast.text, alone)?;
let hints_survive = hint_cost > 0 && text.width() + hint_cost <= alone;
Some((text, status_toast_ink(toast.level), hints_survive))
});
let mut keep_hints = true;
if let Some((text, ink, hints_survive)) = notice {
keep_hints = hints_survive || hint_cost == 0;
left.push(Span::raw(GROUP_GAP));
left.push(Span::styled(
" · ",
Style::default().fg(ChromeInk::MetadataDim.color(&app.ui_theme)),
));
left.push(Span::styled(
truncate_to_width(toast.text.trim(), toast_budget),
Style::default().fg(status_toast_ink(toast.level).color(&app.ui_theme)),
text,
Style::default().fg(ink.color(&app.ui_theme)),
));
} else {
// No notice: the standing facts own the row. Identity first, then the
// ledger, then the session metrics strip on whatever is genuinely
// left. Each group fits whole or is not painted.
let mut used = phase_width + hint_cost;
let identity_budget = available.saturating_sub(used + GROUP_GAP_WIDTH);
if let Some(fields) = route_identity_fields(app, tier, identity_budget) {
let model_index = usize::from(fields.len() == 3);
let spans = identity_spans(&fields, model_index, &app.ui_theme);
used += GROUP_GAP_WIDTH + span_width(&spans);
left.push(Span::raw(GROUP_GAP));
left.extend(spans);
}
let mut ledger: Vec<Span<'static>> = Vec::new();
let chip = app.cumulative_usage_chip();
if tier != ShellTier::Compact
&& let Some(amount) = match &chip {
crate::route_billing::UsageChip::Money(amount) => Some(amount.clone()),
crate::route_billing::UsageChip::PricedSubtotal { .. } => {
crate::route_billing::format_usage_chip(&chip)
}
_ => None,
}
&& amount.width() + GROUP_GAP_WIDTH <= available.saturating_sub(used)
{
used += GROUP_GAP_WIDTH + amount.width();
ledger.push(Span::raw(GROUP_GAP));
ledger.push(Span::styled(
amount,
Style::default().fg(ChromeInk::Metadata.color(&app.ui_theme)),
));
}
// The session metrics strip owns the cache cell when it is on; the
// standalone `cache N%` chip stays for users who turned the strip off.
let metrics_enabled = app
.status_items
.contains(&crate::config::StatusItem::SessionMetrics);
if !metrics_enabled
&& tier != ShellTier::Compact
&& app.status_items.contains(&crate::config::StatusItem::Cache)
&& let Some(pct) = session_cache_hit_percentage(app)
{
let chip = format!("cache {pct}%");
let separator_width = if ledger.is_empty() {
GROUP_GAP_WIDTH
} else {
ITEM_SEPARATOR_WIDTH
};
if chip.width() + separator_width <= available.saturating_sub(used) {
used += separator_width + chip.width();
ledger.push(if ledger.is_empty() {
Span::raw(GROUP_GAP)
} else {
Span::styled(
ITEM_SEPARATOR,
Style::default().fg(ChromeInk::MetadataDim.color(&app.ui_theme)),
)
});
ledger.push(Span::styled(
chip,
Style::default().fg(ChromeInk::Metadata.color(&app.ui_theme)),
));
}
}
// Session metrics strip (`4 turns · 108 steps │ LLM 11m46s · tools
// 1m52s │ TTFT 1.5s · 120 tok/s │ cache 99% │ in 9.3M`). It takes
// whatever columns are genuinely free and sheds its lowest-value
// groups to fit rather than truncating a number.
if metrics_enabled && tier != ShellTier::Compact {
let snapshot = crate::tui::session_metrics::snapshot_from_app(app);
if !snapshot.is_empty() {
let budget = available.saturating_sub(used + LEDGER_OPENER_WIDTH);
let ascii = crate::tui::color_compat::ascii_safe_enabled();
let strip = crate::tui::session_metrics::fit_to_width(
crate::tui::session_metrics::build_groups(snapshot, app.ui_locale),
budget,
crate::tui::session_metrics::Separators::for_ascii(ascii),
);
if !strip.is_empty() {
// The gutter opens the ledger group; the bar is the
// metrics strip's own internal divider, so it only shows
// up once the group is already open.
ledger.push(if ledger.is_empty() {
Span::raw(GROUP_GAP)
} else {
Span::styled(
if ascii { " | " } else { " │ " },
Style::default().fg(ChromeInk::MetadataDim.color(&app.ui_theme)),
)
});
ledger.extend(crate::tui::session_metrics::spans(&strip, &app.ui_theme));
}
}
}
left.extend(ledger);
}
left.extend(tail);
let left_width = span_width(&left);
if right_width > 0 && left_width + right_width < available {
left.push(Span::raw(" ".repeat(available - left_width - right_width)));
if keep_hints && hint_width > 0 && left_width + hint_width < available {
left.push(Span::raw(" ".repeat(available - left_width - hint_width)));
left.push(Span::styled(
right_text.into_owned(),
Style::default().fg(ChromeInk::MetadataHint.color(&app.ui_theme)),
@@ -375,17 +528,25 @@ pub fn render(area: Rect, buf: &mut Buffer, app: &mut App) {
Paragraph::new(Line::from(left)).render(area, buf);
}
/// Width of the ` · ` separator painted before the toast.
const TOAST_SEPARATOR_WIDTH: usize = 3;
/// Width of the ` │ ` separator painted before the session metrics strip.
const METRICS_SEPARATOR_WIDTH: usize = 3;
/// Blank columns kept between the toast and the right-aligned key hints, so
/// the two never read as one run-on sentence.
const TOAST_RIGHT_GAP: usize = 2;
/// Floor for the toast budget. Below this the strip is too narrow to say
/// anything useful either way, and clamping keeps the arithmetic from
/// collapsing the toast to nothing on a cramped terminal.
const TOAST_MIN_WIDTH: usize = 24;
/// Peers inside one group — provider and model, a count and its verb — keep
/// the middle dot.
const ITEM_SEPARATOR: &str = " · ";
const ITEM_SEPARATOR_WIDTH: usize = 3;
/// Blank gutter between the rail's semantic groups.
///
/// The rail used to join every group with that same ` · ` in that same ink,
/// so live state, route identity, a privacy notice, and keyboard hints all
/// read as one run-on list of peers. A dot separates peers inside a group; a
/// gutter separates groups. Same three columns, no extra ink, and the eye
/// finally has something to skim by.
const GROUP_GAP: &str = " ";
const GROUP_GAP_WIDTH: usize = 3;
/// Blank columns kept between the left run and the right-aligned key hints.
const HINT_GAP: usize = 2;
/// Width of whichever mark opens the session metrics strip — the group
/// gutter when the ledger starts with it, the ` │ ` bar when chips came
/// first. Both are three columns.
const LEDGER_OPENER_WIDTH: usize = 3;
#[cfg(test)]
mod tests {
@@ -839,6 +1000,242 @@ mod tests {
assert!(!text.contains("│"), "{text}");
}
/// The whole point of the gutter: four kinds of fact used to be strung
/// together with one separator at one weight, so nothing was grouped and
/// the eye had nothing to skim by.
#[test]
fn live_state_and_route_identity_are_divided_by_a_gutter_not_a_dot() {
let mut app = test_app();
app.ui_locale = crate::localization::Locale::En;
let text = strip_text(&mut app, 120);
let label = text.find("idle").expect(&text);
assert!(
text[label..].starts_with("idle DeepSeek"),
"phase and identity must be divided by the group gutter: {text}"
);
// Peers inside the identity group keep the middle dot.
assert!(
text.contains("DeepSeek · deepseek-v4-flash · max"),
"{text}"
);
}
/// `/context:context` cost eighteen columns to advertise something the
/// composer announces the moment you type `/`. The rail now reads the
/// same at 80 columns as at 200.
#[test]
fn the_rail_advertises_chords_not_slash_commands() {
let mut app = test_app();
app.ui_locale = crate::localization::Locale::En;
for width in [80, 120, 200] {
let text = strip_text(&mut app, width);
assert!(text.contains("keys"), "{width}: {text}");
assert!(
!text.contains("/context"),
"{width} advertised a slash command: {text}"
);
}
}
#[test]
fn notice_clauses_split_on_sentences_and_keep_versions_and_paths_whole() {
assert_eq!(
notice_clauses(
"Counts are on. Code is never collected. See docs/T.md",
&SENTENCE_MARKS
),
vec![
"Counts are on.",
"Code is never collected.",
"See docs/T.md"
]
);
assert_eq!(
notice_clauses("Updated to 0.9.11 from 0.9.10", &SENTENCE_MARKS),
vec!["Updated to 0.9.11 from 0.9.10"]
);
// Full-width stops carry no trailing space, so they break on sight.
assert_eq!(
notice_clauses(
"匿名の利用回数は有効です。会話とコードは収集されません。",
&SENTENCE_MARKS
),
vec![
"匿名の利用回数は有効です。",
"会話とコードは収集されません。"
]
);
// A colon inside a URL is not a joint.
assert_eq!(
notice_clauses("Docs: https://example.test/x", &CLAUSE_MARKS),
vec!["Docs:", "https://example.test/x"]
);
}
/// `。` already carries its own breathing room; a Latin space after it is
/// a typographic accent in the wrong language.
#[test]
fn shed_clauses_rejoin_without_a_latin_space_after_a_full_width_stop() {
const JA: &str = "匿名の利用状況集計はオンです。会話やコードは一切収集しません。/settings で変更できます。スキーマ: docs/TELEMETRY.md";
assert_eq!(
fit_notice(JA, 62).as_deref(),
Some("匿名の利用状況集計はオンです。会話やコードは一切収集しません。")
);
assert_eq!(
fit_notice(JA, 40).as_deref(),
Some("匿名の利用状況集計はオンです。")
);
}
#[test]
fn a_notice_sheds_whole_clauses_and_never_dangles() {
const NOTICE: &str = "Anonymous usage counts are on. Conversations and code are never collected. Change this in /settings; schema: docs/TELEMETRY.md";
assert_eq!(fit_notice(NOTICE, 200).as_deref(), Some(NOTICE));
assert_eq!(
fit_notice(NOTICE, 80).as_deref(),
Some("Anonymous usage counts are on. Conversations and code are never collected.")
);
assert_eq!(
fit_notice(NOTICE, 40).as_deref(),
Some("Anonymous usage counts are on.")
);
assert_eq!(fit_notice(" ", 40), None);
}
/// The failure this caught: a one-sentence warning longer than the row
/// used to have no sentence joint to shed at, so the rail dropped the
/// whole warning. Inner joints are the fallback, and the phrase that
/// survives never ends on a `:` or `;` — that mark says "more is coming"
/// as loudly as an ellipsis does.
#[test]
fn a_clause_less_warning_sheds_at_inner_joints_rather_than_vanishing() {
const WARNING: &str =
"Auto-denied exec_shell: denied earlier; restart Codewhale to re-enable it.";
assert_eq!(fit_notice(WARNING, 120).as_deref(), Some(WARNING));
assert_eq!(
fit_notice(WARNING, 60).as_deref(),
Some("Auto-denied exec_shell: denied earlier")
);
assert_eq!(
fit_notice(WARNING, 30).as_deref(),
Some("Auto-denied exec_shell")
);
let mut app = test_app();
app.ui_locale = crate::localization::Locale::En;
app.push_status_toast(WARNING, crate::tui::app::StatusToastLevel::Warning, None);
for width in [60u16, 72, 80, 100, 120] {
let text = strip_text(&mut app, width);
assert!(
text.contains("Auto-denied exec_shell"),
"{width} dropped the warning: {text}"
);
assert!(!text.contains('…'), "{width} dangled: {text}");
}
}
/// A notice is a transient thing the session is telling you; identity and
/// the ledger are standing facts that will still be there in ten seconds.
/// While a notice is live the standing facts stand down, so the notice
/// gets a whole sentence instead of a stump.
#[test]
fn a_live_notice_stands_the_standing_facts_down() {
let mut app = test_app();
app.ui_locale = crate::localization::Locale::En;
app.status_items = vec![crate::config::StatusItem::SessionMetrics];
app.turn_counter = 4;
app.push_status_toast(
"Anonymous usage counts are on. Conversations and code are never collected. Change this in /settings; schema: docs/TELEMETRY.md",
crate::tui::app::StatusToastLevel::Info,
Some(12_000),
);
let text = strip_text(&mut app, 120);
assert!(
text.contains(
"Anonymous usage counts are on. Conversations and code are never collected."
),
"{text}"
);
assert!(
!text.contains("deepseek-v4-flash"),
"identity should stand down for a live notice: {text}"
);
assert!(!text.contains("turns"), "ledger stood down too: {text}");
assert!(text.contains("keys"), "hints still fit at 120: {text}");
// 80 columns holds one clause beside the hints.
let text = strip_text(&mut app, 80);
assert!(text.contains("Anonymous usage counts are on."), "{text}");
assert!(!text.contains("Conversations"), "{text}");
assert!(text.contains("keys"), "{text}");
// 60 columns cannot hold a clause and the hints, so the hints — the
// one group that is optional twice over — yield last and yield whole.
let text = strip_text(&mut app, 60);
assert!(text.contains("Anonymous usage counts are on."), "{text}");
assert!(!text.contains("keys"), "{text}");
}
#[test]
fn nothing_on_the_rail_advertises_truncation() {
let mut app = test_app();
app.ui_locale = crate::localization::Locale::En;
app.status_items = vec![crate::config::StatusItem::SessionMetrics];
app.turn_counter = 4;
for width in [40u16, 50, 59, 60, 72, 80, 100, 120, 160] {
let clean = strip_text(&mut app, width);
assert!(!clean.contains('…'), "{width} dangled: {clean}");
}
app.push_status_toast(
"Anonymous usage counts are on. Conversations and code are never collected. Change this in /settings; schema: docs/TELEMETRY.md",
crate::tui::app::StatusToastLevel::Info,
Some(12_000),
);
for width in [40u16, 50, 59, 60, 72, 80, 100, 120, 160] {
let noticed = strip_text(&mut app, width);
assert!(!noticed.contains('…'), "{width} dangled: {noticed}");
}
}
/// `deepseek-v4-flash-prev…` could be any of several routes. A clipped
/// model name is worse than no model name, so the qualifiers go first.
#[test]
fn identity_sheds_qualifiers_before_it_would_clip_a_model_name() {
let mut app = App::new(
TuiOptions {
model: "deepseek-v4-flash-preview-2026-05-01".to_string(),
..crate::test_support::test_tui_options(PathBuf::from("."))
},
&Config::default(),
);
app.ui_locale = crate::localization::Locale::En;
let wide = strip_text(&mut app, 140);
assert!(
wide.contains("DeepSeek · deepseek-v4-flash-preview-2026-05-01 · max"),
"{wide}"
);
let mid = strip_text(&mut app, 80);
assert!(
mid.contains("deepseek-v4-flash-preview-2026-05-01"),
"{mid}"
);
assert!(!mid.contains("DeepSeek ·"), "provider sheds first: {mid}");
// Narrow enough that only the bare model name survives; it survives
// whole or not at all.
for width in [50u16, 60, 70] {
let narrow = strip_text(&mut app, width);
assert!(
!narrow.contains("deepseek-v4-flash-p")
|| narrow.contains("deepseek-v4-flash-preview-2026-05-01"),
"{width} clipped the model name: {narrow}"
);
}
}
#[test]
fn session_metrics_strip_is_on_by_default() {
assert!(