| 1 | //! The shell's metrics line — one row of session numbers, painted under the |
| 2 | //! posture bar at the bottom of the screen. |
| 3 | //! |
| 4 | //! It used to be a top bar. The founder's call (SHELL-DESIGN-20260901 §2.0): |
| 5 | //! *"Putting the info at the bottom is a better idea, because then you scroll |
| 6 | //! up and it feels intentional. Move the top/side bar to the bottom."* Then |
| 7 | //! (2026-09-02): *less always-on information* — the repository and branch |
| 8 | //! moved to the launch header and the git bottom view, and the DeepSeek |
| 9 | //! harness session metrics came back on screen in their place. |
| 10 | //! |
| 11 | //! The row, left to right, separated by three spaces: |
| 12 | //! |
| 13 | //! ```text |
| 14 | //! deepseek-v4 ctx 22% $0.14 ttft 400ms 38 tok/s ↓ 1.2K Ctrl+/ help |
| 15 | //! ``` |
| 16 | //! |
| 17 | //! The model is the one route fact the user checks before a turn, and it |
| 18 | //! stays clickable to the picker; the context reading stays clickable to the |
| 19 | //! inspector. Both are the floor and never shed. Everything else is a |
| 20 | //! metric: the session cost (the same number `/cost`, the roster and the |
| 21 | //! price widget print), time to first token, output rate and output tokens — |
| 22 | //! measured latency/rate averages persist between receipts; the output count |
| 23 | //! updates during streaming. Missing measurements remain absent. |
| 24 | //! |
| 25 | //! The context reading is painted here and only here — the posture bar above |
| 26 | //! used to print the same percentage a second time from the same snapshot — |
| 27 | //! and at every fullness, not only from 50% up (#5950). |
| 28 | //! |
| 29 | //! Shed order as width drops: cache, output count and billing tier, the help |
| 30 | //! hint, then rate and TTFT, then cost and balance |
| 31 | //! ([`InfoSegmentId::shed_priority`]). The model and `ctx NN%` never shed; below that floor the row clips at its |
| 32 | //! right edge. |
| 33 | //! |
| 34 | //! Which segments exist at all is the user's call: `/statusline` and |
| 35 | //! `tui.status_items` compose the row, and [`crate::tui::ui::frame::info_segments`] |
| 36 | //! builds only the ones that are on. Shedding decides what survives the |
| 37 | //! width that is left. `tui.metrics_line` sizes the row (#5950): `hidden` |
| 38 | //! gives the line back to the transcript, and `compact` starts the shed |
| 39 | //! pass with secondary counts and the help hint already gone; TTFT and rate |
| 40 | //! remain when selected and space allows |
| 41 | //! ([`InfoLine::compact`]). |
| 42 | //! |
| 43 | //! Interaction: segment geometry is recorded for parity tests, but only the |
| 44 | //! model/route segment and the context reading advertise an action in the |
| 45 | //! live shell. Status-only facts do not brighten on hover or pretend to be |
| 46 | //! controls. |
| 47 | //! |
| 48 | //! Color: semantic ink only ([`ChromeInk`]); no hex, per the status-bar color |
| 49 | //! grammar. ASCII-safe mode substitutes every glyph through |
| 50 | //! [`glyphs::ascii_fallback`]. |
| 51 | |
| 52 | use ratatui::{ |
| 53 | buffer::Buffer, |
| 54 | layout::Rect, |
| 55 | style::{Modifier, Style}, |
| 56 | text::Span, |
| 57 | widgets::Widget, |
| 58 | }; |
| 59 | use unicode_width::UnicodeWidthStr; |
| 60 | |
| 61 | use crate::tui::glyphs; |
| 62 | use codewhale_palette::{ChromeInk, UiTheme}; |
| 63 | |
| 64 | /// Separator between items — the row's one piece of punctuation. |
| 65 | const ITEM_JOIN: &str = " "; |
| 66 | /// Minimum gap between the last left item and the pinned help hint. |
| 67 | const HELP_GAP: usize = 2; |
| 68 | |
| 69 | /// Identity of a metrics-line segment. The live shell registers an action |
| 70 | /// for [`Self::Model`] (the provider picker) and [`Self::Context`] (the |
| 71 | /// context inspector); the rest are readings. |
| 72 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 73 | pub enum InfoSegmentId { |
| 74 | /// Effective model / route — click opens the provider picker. |
| 75 | Model, |
| 76 | /// Context window reading `ctx NN%` — click opens the inspector. |
| 77 | Context, |
| 78 | /// Session cost, the one price number (`$0.14`). |
| 79 | Cost, |
| 80 | /// The clock-dependent billing tier of the active route (`peak` / |
| 81 | /// `off-peak`), painted beside the cost only for routes whose rates move |
| 82 | /// with the clock (DeepSeek V4 Pro/Flash and Flash). |
| 83 | BillingTier, |
| 84 | /// Output tokens of the live or last turn (`↓ 1.2K`). |
| 85 | OutputTokens, |
| 86 | /// Time to first token (`ttft 400ms`). |
| 87 | Ttft, |
| 88 | /// Output rate (`38 tok/s`). |
| 89 | Rate, |
| 90 | /// Prompt cache hit percent (`cache 85%`). |
| 91 | Cache, |
| 92 | /// Prepaid credit left on the active route (`balance $4.32`). Opt-in: |
| 93 | /// only painted when `/statusline` has the balance item on, which is |
| 94 | /// also what authorises the fetch behind it. |
| 95 | Balance, |
| 96 | /// Active goal with elapsed time and the model's reported progress |
| 97 | /// (`Goal (9m) 12% ▓▓░░░░░░`). Painted only while a goal is active. |
| 98 | Goal, |
| 99 | /// Session workspace leaf directory, left-truncated (`…atch/codewhale`). |
| 100 | /// Opt-in via `/statusline` (#6112). |
| 101 | Workspace, |
| 102 | /// Current git branch from the cached workspace context, or the short |
| 103 | /// SHA when HEAD is detached. Absent outside a repository. Opt-in via |
| 104 | /// `/statusline` (#6112). |
| 105 | GitBranch, |
| 106 | } |
| 107 | |
| 108 | impl InfoSegmentId { |
| 109 | /// Shed priority: higher sheds first as width drops. `0` never sheds. |
| 110 | /// Segments at or above [`Self::SHED_BEFORE_HELP`] go before the help |
| 111 | /// hint; the cost outlives the hint because it is the one number that |
| 112 | /// must keep matching `/cost`, the roster and the price widget. |
| 113 | #[must_use] |
| 114 | pub fn shed_priority(self) -> u8 { |
| 115 | match self { |
| 116 | Self::Cache => 8, |
| 117 | // Performance readings outlive help and secondary counts. |
| 118 | Self::Rate | Self::Ttft => 6, |
| 119 | Self::OutputTokens => 7, |
| 120 | // The tier is a reading about the cost, not the cost: it sheds |
| 121 | // with the telemetry, ahead of the number it annotates. |
| 122 | Self::BillingTier => 7, |
| 123 | Self::Cost => 5, |
| 124 | // The balance outlives the cost: it is off by default, so a row |
| 125 | // that shows one is a row whose owner asked for it by name. |
| 126 | Self::Balance => 4, |
| 127 | // An active goal is the session's deliberate long-running mode: |
| 128 | // its reading outlives every telemetry segment and sheds only |
| 129 | // ahead of the route and context readings. |
| 130 | Self::Goal => 3, |
| 131 | // Workspace and branch are opt-in like the balance: a row that |
| 132 | // shows them is a row whose owner asked by name, so they shed |
| 133 | // with it, ahead of telemetry but behind the goal. |
| 134 | Self::Workspace | Self::GitBranch => 4, |
| 135 | Self::Model | Self::Context => 0, |
| 136 | } |
| 137 | } |
| 138 | |
| 139 | /// Priorities at or above this shed before the help hint does. |
| 140 | const SHED_BEFORE_HELP: u8 = 7; |
| 141 | } |
| 142 | |
| 143 | /// One metrics-line segment. |
| 144 | #[derive(Debug, Clone)] |
| 145 | pub struct InfoSegment { |
| 146 | pub id: InfoSegmentId, |
| 147 | pub label: String, |
| 148 | pub value: String, |
| 149 | pub ink: ChromeInk, |
| 150 | } |
| 151 | |
| 152 | impl InfoSegment { |
| 153 | #[must_use] |
| 154 | pub fn new(id: InfoSegmentId, label: &str, value: impl Into<String>, ink: ChromeInk) -> Self { |
| 155 | Self { |
| 156 | id, |
| 157 | label: label.to_string(), |
| 158 | value: value.into(), |
| 159 | ink, |
| 160 | } |
| 161 | } |
| 162 | |
| 163 | fn rendered_width(&self, ascii_safe: bool) -> usize { |
| 164 | segment_text(self, ascii_safe).width() |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | fn segment_text(segment: &InfoSegment, ascii_safe: bool) -> String { |
| 169 | if segment.label.is_empty() { |
| 170 | segment.value.clone() |
| 171 | } else { |
| 172 | format!("{} {}", sym(&segment.label, ascii_safe), segment.value) |
| 173 | } |
| 174 | } |
| 175 | |
| 176 | /// What the caller owes the metrics line. Everything is injected so renders |
| 177 | /// are deterministic (golden buffers) and wall-clock keyed by the owner, |
| 178 | /// never frame-count keyed (spec §5e). |
| 179 | pub struct InfoLine<'a> { |
| 180 | pub theme: &'a UiTheme, |
| 181 | /// The single right-hand key hint, e.g. `Ctrl+/ help`. Empty means the |
| 182 | /// caller has no hint to advertise. |
| 183 | pub help_hint: &'a str, |
| 184 | /// Segments in display order. |
| 185 | pub segments: &'a [InfoSegment], |
| 186 | /// Actionable segment under the mouse. [`InfoSegmentId::Model`] and |
| 187 | /// [`InfoSegmentId::Context`] advertise hover feedback in the live |
| 188 | /// shell; both own a click action (picker / inspector). |
| 189 | pub hovered: Option<InfoSegmentId>, |
| 190 | /// ASCII-safe / NO_COLOR mode: every glyph goes through |
| 191 | /// [`glyphs::ascii_fallback`]. |
| 192 | pub ascii_safe: bool, |
| 193 | /// `tui.metrics_line = "compact"` (#5950): the shed pass starts with |
| 194 | /// secondary counts (everything at or above |
| 195 | /// [`InfoSegmentId::SHED_BEFORE_HELP`]) and the help hint already gone. |
| 196 | /// Selected TTFT and rate readings remain; width sheds the rest. |
| 197 | pub compact: bool, |
| 198 | } |
| 199 | |
| 200 | impl<'a> InfoLine<'a> { |
| 201 | #[must_use] |
| 202 | pub fn new(theme: &'a UiTheme, help_hint: &'a str, segments: &'a [InfoSegment]) -> Self { |
| 203 | Self { |
| 204 | theme, |
| 205 | help_hint, |
| 206 | segments, |
| 207 | hovered: None, |
| 208 | ascii_safe: false, |
| 209 | compact: false, |
| 210 | } |
| 211 | } |
| 212 | |
| 213 | #[must_use] |
| 214 | pub fn ascii_safe(mut self, ascii_safe: bool) -> Self { |
| 215 | self.ascii_safe = ascii_safe; |
| 216 | self |
| 217 | } |
| 218 | |
| 219 | #[must_use] |
| 220 | pub fn compact(mut self, compact: bool) -> Self { |
| 221 | self.compact = compact; |
| 222 | self |
| 223 | } |
| 224 | |
| 225 | #[must_use] |
| 226 | pub fn hovered(mut self, hovered: Option<InfoSegmentId>) -> Self { |
| 227 | self.hovered = hovered; |
| 228 | self |
| 229 | } |
| 230 | } |
| 231 | |
| 232 | fn ascii_of(glyph: &str) -> String { |
| 233 | if let Some(fb) = glyphs::ascii_fallback(glyph) { |
| 234 | return fb.to_string(); |
| 235 | } |
| 236 | glyph |
| 237 | .chars() |
| 238 | .map(|c| { |
| 239 | glyphs::ascii_fallback(&c.to_string()) |
| 240 | .map(str::to_string) |
| 241 | .unwrap_or_else(|| c.to_string()) |
| 242 | }) |
| 243 | .collect() |
| 244 | } |
| 245 | |
| 246 | fn sym(glyph: &str, ascii_safe: bool) -> String { |
| 247 | if ascii_safe { |
| 248 | ascii_of(glyph) |
| 249 | } else { |
| 250 | glyph.to_string() |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | /// The shed pass's answer: which segments survive at this row width and |
| 255 | /// whether the help hint survived. Shared by the render and the hitbox |
| 256 | /// computation so the two can never disagree about the cells a segment |
| 257 | /// painted. |
| 258 | struct ShedRow<'t> { |
| 259 | kept: Vec<&'t InfoSegment>, |
| 260 | show_help: bool, |
| 261 | } |
| 262 | |
| 263 | fn shed_pass<'t>(info: &'t InfoLine<'_>, area: Rect) -> ShedRow<'t> { |
| 264 | let ascii = info.ascii_safe; |
| 265 | let help = sym(info.help_hint, ascii); |
| 266 | let join_w = sym(ITEM_JOIN, ascii).width(); |
| 267 | // A compact row is the full row after its first shed rungs: the |
| 268 | // secondary counts and the help hint go before width is consulted. |
| 269 | let mut kept: Vec<&InfoSegment> = info |
| 270 | .segments |
| 271 | .iter() |
| 272 | .filter(|segment| { |
| 273 | !info.compact || segment.id.shed_priority() < InfoSegmentId::SHED_BEFORE_HELP |
| 274 | }) |
| 275 | .collect(); |
| 276 | let left_width = |segs: &[&InfoSegment]| -> usize { |
| 277 | segs.iter().map(|s| s.rendered_width(ascii)).sum::<usize>() |
| 278 | + join_w * segs.len().saturating_sub(1) |
| 279 | }; |
| 280 | let total_needed = |left: usize, show_help: bool| -> usize { |
| 281 | left + if show_help && !help.is_empty() { |
| 282 | HELP_GAP + help.width() |
| 283 | } else { |
| 284 | 0 |
| 285 | } |
| 286 | }; |
| 287 | // The highest-priority shedding segment, restricted to `min_priority` |
| 288 | // and above, if any. |
| 289 | let sheddable = |kept: &[&InfoSegment], min_priority: u8| -> Option<usize> { |
| 290 | kept.iter() |
| 291 | .enumerate() |
| 292 | .filter(|(_, s)| s.id.shed_priority() >= min_priority.max(1)) |
| 293 | .max_by_key(|(_, s)| s.id.shed_priority()) |
| 294 | .map(|(i, _)| i) |
| 295 | }; |
| 296 | |
| 297 | let mut show_help = !help.is_empty() && !info.compact; |
| 298 | while total_needed(left_width(&kept), show_help) > area.width as usize { |
| 299 | if let Some(pos) = sheddable(&kept, InfoSegmentId::SHED_BEFORE_HELP) { |
| 300 | kept.remove(pos); |
| 301 | } else if show_help { |
| 302 | show_help = false; |
| 303 | } else if let Some(pos) = sheddable(&kept, 1) { |
| 304 | kept.remove(pos); |
| 305 | } else { |
| 306 | break; |
| 307 | } |
| 308 | } |
| 309 | |
| 310 | ShedRow { kept, show_help } |
| 311 | } |
| 312 | |
| 313 | /// The context reading's hitbox (spec §6: the reading is the chrome row's one |
| 314 | /// always-present inspector target — `/context`'s mouse route). |
| 315 | #[must_use] |
| 316 | pub fn context_meter_hitbox(info: &InfoLine<'_>, area: Rect) -> Option<Rect> { |
| 317 | infoline_hitboxes(info, area) |
| 318 | .into_iter() |
| 319 | .find(|hitbox| hitbox.id == InfoSegmentId::Context) |
| 320 | .map(|hitbox| hitbox.area) |
| 321 | } |
| 322 | |
| 323 | impl Widget for InfoLine<'_> { |
| 324 | fn render(self, area: Rect, buf: &mut Buffer) { |
| 325 | if area.height < 1 || area.width < 1 { |
| 326 | return; |
| 327 | } |
| 328 | let theme = self.theme; |
| 329 | let ascii = self.ascii_safe; |
| 330 | let ShedRow { kept, show_help } = shed_pass(&self, area); |
| 331 | |
| 332 | let right_edge = usize::from(area.x) + usize::from(area.width); |
| 333 | let mut x = usize::from(area.x); |
| 334 | let y = area.y; |
| 335 | let join = sym(ITEM_JOIN, ascii); |
| 336 | // Every write clips at the row's right edge, so a row below the |
| 337 | // floor (model + context) truncates rather than wraps or panics. |
| 338 | let set = |buf: &mut Buffer, cx: usize, span: &Span<'_>| { |
| 339 | let budget = right_edge.saturating_sub(cx); |
| 340 | if budget > 0 { |
| 341 | buf.set_span(cx as u16, y, span, budget as u16); |
| 342 | } |
| 343 | }; |
| 344 | |
| 345 | for (index, segment) in kept.iter().enumerate() { |
| 346 | if index > 0 { |
| 347 | set( |
| 348 | buf, |
| 349 | x, |
| 350 | &Span::styled(&join, chrome(theme, ChromeInk::MetadataDim)), |
| 351 | ); |
| 352 | x += join.width(); |
| 353 | } |
| 354 | // Slice G global rule: every actionable segment brightens on |
| 355 | // hover. Model and Context own click actions; status-only |
| 356 | // facts never do. |
| 357 | let hovered = matches!(segment.id, InfoSegmentId::Model | InfoSegmentId::Context) |
| 358 | && self.hovered == Some(segment.id); |
| 359 | let mut style = chrome(theme, segment.ink); |
| 360 | if hovered { |
| 361 | style = style |
| 362 | .add_modifier(Modifier::BOLD) |
| 363 | .add_modifier(Modifier::UNDERLINED); |
| 364 | } |
| 365 | // label dim, value in the segment's ink (two spans, one hitbox). |
| 366 | // The label may be a glyph (`↓`); ascii-safe projects it, and |
| 367 | // every projection is single-width so the shed arithmetic above |
| 368 | // stays exact. |
| 369 | if !segment.label.is_empty() { |
| 370 | // A reading that has become a problem reads as one warning, |
| 371 | // not a gray word beside a red number. |
| 372 | let label_ink = match segment.ink { |
| 373 | ChromeInk::Failure | ChromeInk::Attention => segment.ink, |
| 374 | _ => ChromeInk::Metadata, |
| 375 | }; |
| 376 | let label = sym(&segment.label, ascii); |
| 377 | set( |
| 378 | buf, |
| 379 | x, |
| 380 | &Span::styled(label.clone(), chrome(theme, label_ink)), |
| 381 | ); |
| 382 | x += label.width() + 1; |
| 383 | } |
| 384 | set(buf, x, &Span::styled(&segment.value, style)); |
| 385 | x += segment.value.width(); |
| 386 | } |
| 387 | |
| 388 | // The help hint is pinned to the row's right edge. |
| 389 | if show_help { |
| 390 | let hint = sym(self.help_hint, ascii); |
| 391 | let sx = right_edge.saturating_sub(hint.width()); |
| 392 | set( |
| 393 | buf, |
| 394 | sx, |
| 395 | &Span::styled(hint, chrome(theme, ChromeInk::MetadataHint)), |
| 396 | ); |
| 397 | } |
| 398 | } |
| 399 | } |
| 400 | |
| 401 | fn chrome(theme: &UiTheme, ink: ChromeInk) -> Style { |
| 402 | codewhale_palette::grammar::chrome_style(theme, ink) |
| 403 | } |
| 404 | |
| 405 | /// Recorded hitboxes for one rendered row. Mirrors the |
| 406 | /// `viewport.last_workflow_cancel_area` storage pattern: render computes the |
| 407 | /// rects, the caller stores them, `mouse_ui` hit-tests against them. |
| 408 | #[derive(Debug, Clone)] |
| 409 | pub struct InfoLineHitbox { |
| 410 | pub id: InfoSegmentId, |
| 411 | pub area: Rect, |
| 412 | } |
| 413 | |
| 414 | /// Compute the hitbox `Rect` for each kept segment. Must be called with the |
| 415 | /// same inputs as the render so the rects match the painted cells exactly. |
| 416 | #[must_use] |
| 417 | pub fn infoline_hitboxes(info: &InfoLine<'_>, area: Rect) -> Vec<InfoLineHitbox> { |
| 418 | let mut out = Vec::new(); |
| 419 | if area.height < 1 || area.width < 1 { |
| 420 | return out; |
| 421 | } |
| 422 | let shed = shed_pass(info, area); |
| 423 | let clip_right = usize::from(area.x) + usize::from(area.width); |
| 424 | let join_width = sym(ITEM_JOIN, info.ascii_safe).width(); |
| 425 | let mut x = usize::from(area.x); |
| 426 | for (index, segment) in shed.kept.iter().enumerate() { |
| 427 | if index > 0 { |
| 428 | x += join_width; |
| 429 | } |
| 430 | let w = segment.rendered_width(info.ascii_safe); |
| 431 | let end = (x + w).min(clip_right); |
| 432 | if x < end { |
| 433 | out.push(InfoLineHitbox { |
| 434 | id: segment.id, |
| 435 | area: Rect { |
| 436 | x: x as u16, |
| 437 | y: area.y, |
| 438 | width: (end - x) as u16, |
| 439 | height: 1, |
| 440 | }, |
| 441 | }); |
| 442 | } |
| 443 | x += w; |
| 444 | } |
| 445 | out |
| 446 | } |
| 447 | |
| 448 | #[cfg(test)] |
| 449 | mod tests; |
| 450 |