返回 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 //! the shared kit. Engine facts and action dispatch remain with the callers.
51
52 use codewhale_palette::{ChromeInk, UiTheme};
53 use codewhale_ratatui::{
54 Caps, MetricSegment, MetricsLine, Paint, Role, Theme, color::ColorDepth, detect::Appearance,
55 };
56 use ratatui::{buffer::Buffer, layout::Rect, style::Color, widgets::Widget};
57
58 /// The kit owns segment identities and their one shared shed priority.
59 /// Existing callers retain the same model/context action identities.
60 pub use codewhale_ratatui::MetricKind as InfoSegmentId;
61
62 /// One metrics-line segment.
63 #[derive(Debug, Clone)]
64 pub struct InfoSegment {
65 pub id: InfoSegmentId,
66 pub label: String,
67 pub value: String,
68 pub ink: ChromeInk,
69 }
70
71 impl InfoSegment {
72 #[must_use]
73 pub fn new(id: InfoSegmentId, label: &str, value: impl Into<String>, ink: ChromeInk) -> Self {
74 Self {
75 id,
76 label: label.to_string(),
77 value: value.into(),
78 ink,
79 }
80 }
81 }
82
83 /// What the caller owes the metrics line. Everything is injected so renders
84 /// are deterministic (golden buffers) and wall-clock keyed by the owner,
85 /// never frame-count keyed (spec §5e).
86 pub struct InfoLine<'a> {
87 pub theme: &'a UiTheme,
88 /// The single right-hand key hint, e.g. `Ctrl+/ help`. Empty means the
89 /// caller has no hint to advertise.
90 pub help_hint: &'a str,
91 /// Segments in display order.
92 pub segments: &'a [InfoSegment],
93 /// Actionable segment under the mouse. [`InfoSegmentId::Model`] and
94 /// [`InfoSegmentId::Context`] advertise hover feedback in the live
95 /// shell; both own a click action (picker / inspector).
96 pub hovered: Option<InfoSegmentId>,
97 /// ASCII-safe / NO_COLOR mode: every glyph goes through
98 /// the kit's native punctuation projection; language text stays intact.
99 pub ascii_safe: bool,
100 /// `tui.metrics_line = "compact"` (#5950): the shed pass starts with
101 /// secondary counts and the help hint already gone.
102 /// Selected TTFT and rate readings remain; width sheds the rest.
103 pub compact: bool,
104 }
105
106 impl<'a> InfoLine<'a> {
107 #[must_use]
108 pub fn new(theme: &'a UiTheme, help_hint: &'a str, segments: &'a [InfoSegment]) -> Self {
109 Self {
110 theme,
111 help_hint,
112 segments,
113 hovered: None,
114 ascii_safe: false,
115 compact: false,
116 }
117 }
118
119 #[must_use]
120 pub fn ascii_safe(mut self, ascii_safe: bool) -> Self {
121 self.ascii_safe = ascii_safe;
122 self
123 }
124
125 #[must_use]
126 pub fn compact(mut self, compact: bool) -> Self {
127 self.compact = compact;
128 self
129 }
130
131 #[must_use]
132 pub fn hovered(mut self, hovered: Option<InfoSegmentId>) -> Self {
133 self.hovered = hovered;
134 self
135 }
136 }
137
138 // A full-color role theme supplies distinct identities, not product colors.
139 // Named native palettes can collapse Hint/Dim or permission inks; use the
140 // uncollapsed role table so custom UiTheme slots remain independent. The
141 // existing backend applies terminal color capabilities after this adapter.
142 pub(super) fn source_theme(ascii: bool) -> Theme {
143 Theme::new(Caps {
144 depth: ColorDepth::TrueColor,
145 ascii,
146 appearance: Appearance::Dark,
147 })
148 }
149
150 pub(super) fn ink_role(ink: ChromeInk) -> Role {
151 match ink {
152 ChromeInk::Outcome | ChromeInk::Active => Role::Live,
153 // These three roles are private style identities in this adapter;
154 // they do not paint grounds or change permission authority.
155 ChromeInk::PermissionAsk => Role::Surface,
156 ChromeInk::PermissionAutoReview => Role::Hover,
157 ChromeInk::PermissionFullAccess => Role::Selected,
158 ChromeInk::Waiting => Role::BorderStrong,
159 ChromeInk::Attention => Role::Attention,
160 ChromeInk::PolicyAct
161 | ChromeInk::PolicyPlan
162 | ChromeInk::PolicyOperate
163 | ChromeInk::Identity
164 | ChromeInk::Info => Role::Primary,
165 ChromeInk::MetadataValue => Role::Foreground,
166 ChromeInk::Metadata => Role::Muted,
167 ChromeInk::MetadataHint => Role::Hint,
168 ChromeInk::MetadataDim => Role::Dim,
169 ChromeInk::Failure => Role::Danger,
170 }
171 }
172
173 const STYLE_INKS: [ChromeInk; 12] = [
174 ChromeInk::Active,
175 ChromeInk::PermissionAsk,
176 ChromeInk::PermissionAutoReview,
177 ChromeInk::PermissionFullAccess,
178 ChromeInk::Waiting,
179 ChromeInk::Attention,
180 ChromeInk::Info,
181 ChromeInk::MetadataValue,
182 ChromeInk::Metadata,
183 ChromeInk::MetadataHint,
184 ChromeInk::MetadataDim,
185 ChromeInk::Failure,
186 ];
187
188 impl InfoLine<'_> {
189 fn kit(&self) -> MetricsLine<'_> {
190 let mut line = MetricsLine::new(
191 self.segments
192 .iter()
193 .map(|segment| {
194 MetricSegment::new(segment.id, segment.label.as_str(), segment.value.as_str())
195 .role(ink_role(segment.ink))
196 })
197 .collect(),
198 )
199 .help_hint(self.help_hint)
200 .compact(self.compact);
201 if let Some(hovered) = self.hovered {
202 line = line.hovered(hovered);
203 }
204 line
205 }
206 }
207
208 /// The kit computes the visible context reading's exact pointer target.
209 #[must_use]
210 pub fn context_meter_hitbox(info: &InfoLine<'_>, area: Rect) -> Option<Rect> {
211 info.kit()
212 .context_hitbox(area, &source_theme(info.ascii_safe))
213 }
214
215 impl Widget for InfoLine<'_> {
216 fn render(self, area: Rect, buf: &mut Buffer) {
217 paint_native_row(&self.kit(), area, buf, self.theme, self.ascii_safe);
218 }
219 }
220
221 /// Adapt the kit's two native chrome rows to the live host ink slots.
222 pub(super) fn paint_native_row(
223 component: &impl Paint,
224 area: Rect,
225 buf: &mut Buffer,
226 theme: &UiTheme,
227 ascii: bool,
228 ) {
229 let area = area.intersection(buf.area);
230 if area.is_empty() {
231 return;
232 }
233 let area = Rect { height: 1, ..area };
234 let source = source_theme(ascii);
235 let inks = STYLE_INKS.map(|ink| (source.color(ink_role(ink)), ink.color(theme)));
236 // Render one borrowed row with a foreground sentinel. Only cells the
237 // kit writes are copied back, preserving untouched content and all
238 // host-owned backgrounds/modifiers. Wide-character continuation cells
239 // reset exactly as they do in a direct Ratatui render.
240 let untouched = Color::Indexed(0);
241 let mut row = Buffer::empty(area);
242 for x in area.left()..area.right() {
243 row[(x, area.y)].clone_from(&buf[(x, area.y)]);
244 row[(x, area.y)].fg = untouched;
245 }
246 component.paint(area, &mut row, &source);
247 for x in area.left()..area.right() {
248 let cell = &mut row[(x, area.y)];
249 if cell.fg == untouched {
250 continue;
251 }
252 if let Some((_, color)) = inks.iter().find(|(identity, _)| *identity == Some(cell.fg)) {
253 cell.fg = *color;
254 }
255 buf[(x, area.y)].clone_from(cell);
256 }
257 }
258
259 /// Host-owned action routing keeps its existing typed facade.
260 #[derive(Debug, Clone)]
261 pub struct InfoLineHitbox {
262 pub id: InfoSegmentId,
263 pub area: Rect,
264 }
265
266 /// Geometry comes from the same kit layout as painting, with no caller
267 /// width calculation, shedding pass or punctuation projection.
268 #[must_use]
269 pub fn infoline_hitboxes(info: &InfoLine<'_>, area: Rect) -> Vec<InfoLineHitbox> {
270 info.kit()
271 .hitboxes(area, &source_theme(info.ascii_safe))
272 .into_iter()
273 .map(|hit| InfoLineHitbox {
274 id: hit.kind,
275 area: hit.area,
276 })
277 .collect()
278 }
279
280 #[cfg(test)]
281 mod tests;
282
282 lines RUST