返回 CodeWhale
infoline.rs
根目录 / crates / tui / src / tui / infoline.rs
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
450 lines RUST