| 1 | //! Live phase band for the underwater shell. |
| 2 | //! |
| 3 | //! The HTML reference attaches activity to the transcript and leaves the |
| 4 | //! composer as the final stable object. That means live phases |
| 5 | //! (working / waiting / approval / failed / done) render **above** the |
| 6 | //! composer, while idle and typing keep a quiet phase line beneath it. |
| 7 | //! |
| 8 | //! This module only decides Ocean placement and paints the one-line band. The |
| 9 | //! Classic shell it used to defer to was removed in 0.9.4 — see the migration |
| 10 | //! shim note at `crates/tui/src/tui/ocean.rs:35` — so there is no |
| 11 | //! footer-below-composer fallback path left. |
| 12 | |
| 13 | use crate::localization::truncate_to_width; |
| 14 | use std::borrow::Cow; |
| 15 | |
| 16 | use ratatui::{ |
| 17 | buffer::Buffer, |
| 18 | layout::Rect, |
| 19 | style::{Modifier, Style}, |
| 20 | text::{Line, Span}, |
| 21 | widgets::{Block, Paragraph, Widget}, |
| 22 | }; |
| 23 | use unicode_width::UnicodeWidthStr; |
| 24 | |
| 25 | use crate::localization::{MessageId, tr}; |
| 26 | use crate::tui::{ |
| 27 | app::App, |
| 28 | underwater::{LiveActivity, ShellPhase, ShellTier, phase_marker_with_activity}, |
| 29 | }; |
| 30 | |
| 31 | /// Where the phase band sits relative to the composer. |
| 32 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 33 | pub enum PhaseStripPlacement { |
| 34 | /// Live activity: phase sits on the transcript side of the prompt. |
| 35 | AboveComposer, |
| 36 | /// Idle / drafting: quiet phase under the prompt. |
| 37 | BelowComposer, |
| 38 | } |
| 39 | |
| 40 | impl PhaseStripPlacement { |
| 41 | /// Live phases stay above the composer so the prompt is the bottom |
| 42 | /// stable object. Idle and typing keep the quiet footer under `❯`. |
| 43 | #[must_use] |
| 44 | pub fn for_phase(phase: ShellPhase) -> Self { |
| 45 | match phase { |
| 46 | ShellPhase::Working |
| 47 | | ShellPhase::Verifying |
| 48 | | ShellPhase::Waiting |
| 49 | | ShellPhase::Approval |
| 50 | | ShellPhase::Failed |
| 51 | | ShellPhase::Done => Self::AboveComposer, |
| 52 | ShellPhase::Idle | ShellPhase::Typing => Self::BelowComposer, |
| 53 | } |
| 54 | } |
| 55 | |
| 56 | #[must_use] |
| 57 | pub fn is_above_composer(self) -> bool { |
| 58 | matches!(self, Self::AboveComposer) |
| 59 | } |
| 60 | } |
| 61 | |
| 62 | /// Fixed one-row reservation for the phase band. |
| 63 | #[must_use] |
| 64 | pub fn height() -> u16 { |
| 65 | 1 |
| 66 | } |
| 67 | |
| 68 | fn span_width(spans: &[Span<'_>]) -> usize { |
| 69 | spans.iter().map(|span| span.content.width()).sum() |
| 70 | } |
| 71 | |
| 72 | /// Compact working detail for the phase band: `×N` for tools or `1m 15s` |
| 73 | /// while the model is thinking. |
| 74 | /// Kept quieter than the classic footer's verbose tool-status line so the |
| 75 | /// transcript owns the ledger and the strip only names the live pulse. |
| 76 | fn working_detail(app: &App, activity: LiveActivity) -> Option<String> { |
| 77 | let running = activity.running_tool_count(); |
| 78 | let secs = app |
| 79 | .turn_started_at |
| 80 | .map(|started| started.elapsed().as_secs()); |
| 81 | match (running, secs) { |
| 82 | (0, Some(secs)) if secs > 0 => Some(crate::elapsed::format_elapsed_secs(secs)), |
| 83 | (n, Some(_)) if n > 0 => Some(format!("×{n}")), |
| 84 | (n, None) if n > 0 => Some(format!("×{n}")), |
| 85 | _ => None, |
| 86 | } |
| 87 | } |
| 88 | |
| 89 | fn session_cache_hit_percentage(app: &App) -> Option<u8> { |
| 90 | let hit = u64::from(app.session.total_cache_hit_tokens); |
| 91 | let miss = u64::from(app.session.total_cache_miss_tokens); |
| 92 | let total = hit + miss; |
| 93 | if total == 0 { |
| 94 | return None; |
| 95 | } |
| 96 | |
| 97 | // Round to the nearest whole percent. Widen before adding so sessions |
| 98 | // with saturated u32 telemetry counters can never render above 100%. |
| 99 | Some(((hit * 100 + total / 2) / total) as u8) |
| 100 | } |
| 101 | |
| 102 | /// Paint the one-line phase rail. Compact left marker (icon + verb + duration) |
| 103 | /// instead of a full-width routine phase band. Amber only for approval/waiting; |
| 104 | /// cyan/teal for routine work. |
| 105 | pub fn render(area: Rect, buf: &mut Buffer, app: &mut App) { |
| 106 | if area.width == 0 || area.height == 0 { |
| 107 | return; |
| 108 | } |
| 109 | let status_toast = app.active_status_toast(); |
| 110 | let activity = LiveActivity::from_app(app); |
| 111 | let phase = ShellPhase::from_app_with_activity(app, activity); |
| 112 | let tier = ShellTier::for_chrome_width(area.width); |
| 113 | // Quiet chrome background — never paint the full row in phase accent. |
| 114 | Block::default() |
| 115 | .style(Style::default().bg(app.ui_theme.footer_bg)) |
| 116 | .render(area, buf); |
| 117 | |
| 118 | // Compact left rail: one accent cell + marker + verb (not a full-width band). |
| 119 | let rail_color = phase.color(app); |
| 120 | let (marker, phase_label) = phase_marker_with_activity(app, phase, activity); |
| 121 | let phase_style = Style::default().fg(rail_color).add_modifier( |
| 122 | if matches!(phase, ShellPhase::Waiting | ShellPhase::Approval) { |
| 123 | Modifier::BOLD |
| 124 | } else { |
| 125 | Modifier::empty() |
| 126 | }, |
| 127 | ); |
| 128 | let mut left = vec![ |
| 129 | Span::styled("▌", phase_style), |
| 130 | Span::styled(marker, phase_style), |
| 131 | Span::raw(" "), |
| 132 | Span::styled(phase_label.clone(), phase_style), |
| 133 | ]; |
| 134 | |
| 135 | if tier != ShellTier::Compact && matches!(phase, ShellPhase::Working | ShellPhase::Verifying) { |
| 136 | if let Some(detail) = working_detail(app, activity) { |
| 137 | left.push(Span::styled( |
| 138 | " · ", |
| 139 | Style::default().fg(app.ui_theme.text_dim), |
| 140 | )); |
| 141 | left.push(Span::styled( |
| 142 | detail, |
| 143 | Style::default().fg(app.ui_theme.status_working), |
| 144 | )); |
| 145 | } |
| 146 | left.push(Span::styled( |
| 147 | " · Esc to interrupt", |
| 148 | Style::default().fg(app.ui_theme.text_dim), |
| 149 | )); |
| 150 | } |
| 151 | |
| 152 | // The ledger chips are built before the toast so the toast can be given |
| 153 | // whatever width is genuinely left over. They are appended after it, so |
| 154 | // the visual order is unchanged. |
| 155 | let mut tail: Vec<Span<'static>> = Vec::new(); |
| 156 | let chip = app.cumulative_usage_chip(); |
| 157 | if tier != ShellTier::Compact |
| 158 | && let Some(amount) = match &chip { |
| 159 | crate::route_billing::UsageChip::Money(amount) => Some(amount.clone()), |
| 160 | crate::route_billing::UsageChip::PricedSubtotal { .. } => { |
| 161 | crate::route_billing::format_usage_chip(&chip) |
| 162 | } |
| 163 | _ => None, |
| 164 | } |
| 165 | { |
| 166 | tail.push(Span::styled( |
| 167 | " · ", |
| 168 | Style::default().fg(app.ui_theme.text_dim), |
| 169 | )); |
| 170 | tail.push(Span::styled( |
| 171 | amount, |
| 172 | Style::default().fg(app.ui_theme.text_muted), |
| 173 | )); |
| 174 | } |
| 175 | |
| 176 | if tier != ShellTier::Compact |
| 177 | && app.status_items.contains(&crate::config::StatusItem::Cache) |
| 178 | && let Some(pct) = session_cache_hit_percentage(app) |
| 179 | { |
| 180 | tail.push(Span::styled( |
| 181 | " · ", |
| 182 | Style::default().fg(app.ui_theme.text_dim), |
| 183 | )); |
| 184 | tail.push(Span::styled( |
| 185 | format!("cache {pct}%"), |
| 186 | Style::default().fg(app.ui_theme.text_muted), |
| 187 | )); |
| 188 | } |
| 189 | |
| 190 | // Live phases keep the strip quiet: no detail-key chorus competing with |
| 191 | // the ledger. Idle/typing may advertise keys on the quiet footer. |
| 192 | // Hints come from shell_key_routing so advertised chords match handlers; |
| 193 | // bare letters are never advertised — the composer owns printable keys. |
| 194 | let right_text: Cow<'static, str> = if PhaseStripPlacement::for_phase(phase).is_above_composer() |
| 195 | { |
| 196 | Cow::Borrowed("") |
| 197 | } else { |
| 198 | use crate::tui::shell_key_routing::{ShellBindingId, binding, footer_action_hints}; |
| 199 | let hint_keys = tr(app.ui_locale, MessageId::FooterHintKeys); |
| 200 | let hint_output = tr(app.ui_locale, MessageId::FooterHintOutput); |
| 201 | let hint_context = tr(app.ui_locale, MessageId::FooterHintContext); |
| 202 | Cow::Owned(match tier { |
| 203 | ShellTier::Compact => { |
| 204 | format!("{}:{hint_keys}", binding(ShellBindingId::Help).footer_chord) |
| 205 | } |
| 206 | ShellTier::Normal => footer_action_hints(false) |
| 207 | .replace("{output}", hint_output.as_ref()) |
| 208 | .replace("{keys}", hint_keys.as_ref()), |
| 209 | ShellTier::Wide => footer_action_hints(true) |
| 210 | .replace("{output}", hint_output.as_ref()) |
| 211 | .replace("{context}", hint_context.as_ref()) |
| 212 | .replace("{keys}", hint_keys.as_ref()), |
| 213 | }) |
| 214 | }; |
| 215 | |
| 216 | let right_width = right_text.width(); |
| 217 | let available = usize::from(area.width); |
| 218 | |
| 219 | if tier != ShellTier::Compact |
| 220 | && let Some(toast) = status_toast.filter(|toast| { |
| 221 | // Completion may land in the same event drain as an approval |
| 222 | // denial. Keep unresolved attention/error receipts visible after |
| 223 | // `done`; only routine informational completion copy yields to the |
| 224 | // stable done marker. |
| 225 | let survives_completion = matches!( |
| 226 | toast.level, |
| 227 | crate::tui::app::StatusToastLevel::Warning |
| 228 | | crate::tui::app::StatusToastLevel::Error |
| 229 | ); |
| 230 | (phase != ShellPhase::Done || survives_completion) |
| 231 | && !toast.text.trim().is_empty() |
| 232 | && toast.text.trim() != phase_label.as_ref() |
| 233 | }) |
| 234 | { |
| 235 | // The budget used to be a flat 40 columns no matter how wide the |
| 236 | // terminal was, which cut a warning whose entire job is to explain an |
| 237 | // unexpected state down to `Delegated coordination unavailable — an…`. |
| 238 | // Spend the row that actually exists: everything left after the phase |
| 239 | // marker, the ledger chips, the key hints, and a gap between them. |
| 240 | let toast_budget = available |
| 241 | .saturating_sub( |
| 242 | span_width(&left) |
| 243 | + TOAST_SEPARATOR_WIDTH |
| 244 | + span_width(&tail) |
| 245 | + right_width |
| 246 | + TOAST_RIGHT_GAP, |
| 247 | ) |
| 248 | .max(TOAST_MIN_WIDTH); |
| 249 | left.push(Span::styled( |
| 250 | " · ", |
| 251 | Style::default().fg(app.ui_theme.text_dim), |
| 252 | )); |
| 253 | left.push(Span::styled( |
| 254 | truncate_to_width(toast.text.trim(), toast_budget), |
| 255 | Style::default().fg(crate::tui::ui::status_color(toast.level)), |
| 256 | )); |
| 257 | } |
| 258 | left.extend(tail); |
| 259 | |
| 260 | let left_width = span_width(&left); |
| 261 | if right_width > 0 && left_width + right_width < available { |
| 262 | left.push(Span::raw(" ".repeat(available - left_width - right_width))); |
| 263 | left.push(Span::styled( |
| 264 | right_text.into_owned(), |
| 265 | Style::default().fg(app.ui_theme.text_hint), |
| 266 | )); |
| 267 | } |
| 268 | Paragraph::new(Line::from(left)).render(area, buf); |
| 269 | } |
| 270 | |
| 271 | /// Width of the ` · ` separator painted before the toast. |
| 272 | const TOAST_SEPARATOR_WIDTH: usize = 3; |
| 273 | /// Blank columns kept between the toast and the right-aligned key hints, so |
| 274 | /// the two never read as one run-on sentence. |
| 275 | const TOAST_RIGHT_GAP: usize = 2; |
| 276 | /// Floor for the toast budget. Below this the strip is too narrow to say |
| 277 | /// anything useful either way, and clamping keeps the arithmetic from |
| 278 | /// collapsing the toast to nothing on a cramped terminal. |
| 279 | const TOAST_MIN_WIDTH: usize = 24; |
| 280 | |
| 281 | #[cfg(test)] |
| 282 | mod tests { |
| 283 | use super::*; |
| 284 | use crate::{ |
| 285 | config::Config, |
| 286 | tui::active_cell::ActiveCell, |
| 287 | tui::app::TuiOptions, |
| 288 | tui::history::{ExecCell, ExecSource, HistoryCell, ToolCell, ToolStatus}, |
| 289 | }; |
| 290 | use ratatui::{Terminal, backend::TestBackend}; |
| 291 | use std::{ |
| 292 | path::PathBuf, |
| 293 | time::{Duration, Instant}, |
| 294 | }; |
| 295 | |
| 296 | fn test_app() -> App { |
| 297 | App::new( |
| 298 | TuiOptions { |
| 299 | model: "deepseek-v4-flash".to_string(), |
| 300 | ..crate::test_support::test_tui_options(PathBuf::from(".")) |
| 301 | }, |
| 302 | &Config::default(), |
| 303 | ) |
| 304 | } |
| 305 | |
| 306 | #[test] |
| 307 | fn live_phases_sit_above_composer_idle_stays_below() { |
| 308 | assert_eq!( |
| 309 | PhaseStripPlacement::for_phase(ShellPhase::Working), |
| 310 | PhaseStripPlacement::AboveComposer |
| 311 | ); |
| 312 | assert_eq!( |
| 313 | PhaseStripPlacement::for_phase(ShellPhase::Waiting), |
| 314 | PhaseStripPlacement::AboveComposer |
| 315 | ); |
| 316 | assert_eq!( |
| 317 | PhaseStripPlacement::for_phase(ShellPhase::Approval), |
| 318 | PhaseStripPlacement::AboveComposer |
| 319 | ); |
| 320 | assert_eq!( |
| 321 | PhaseStripPlacement::for_phase(ShellPhase::Failed), |
| 322 | PhaseStripPlacement::AboveComposer |
| 323 | ); |
| 324 | assert_eq!( |
| 325 | PhaseStripPlacement::for_phase(ShellPhase::Done), |
| 326 | PhaseStripPlacement::AboveComposer |
| 327 | ); |
| 328 | assert_eq!( |
| 329 | PhaseStripPlacement::for_phase(ShellPhase::Idle), |
| 330 | PhaseStripPlacement::BelowComposer |
| 331 | ); |
| 332 | assert_eq!( |
| 333 | PhaseStripPlacement::for_phase(ShellPhase::Typing), |
| 334 | PhaseStripPlacement::BelowComposer |
| 335 | ); |
| 336 | } |
| 337 | |
| 338 | #[test] |
| 339 | fn working_marker_uses_the_live_work_status_role() { |
| 340 | let app = test_app(); |
| 341 | assert_eq!(ShellPhase::Working.color(&app), app.ui_theme.status_working); |
| 342 | assert_ne!(ShellPhase::Working.color(&app), app.ui_theme.info); |
| 343 | } |
| 344 | |
| 345 | #[test] |
| 346 | fn working_band_names_tool_use_and_bounded_count_without_key_chorus() { |
| 347 | let mut app = test_app(); |
| 348 | app.ui_locale = crate::localization::Locale::En; |
| 349 | app.is_loading = true; |
| 350 | app.turn_started_at = Some(Instant::now() - Duration::from_secs(12)); |
| 351 | let mut active = ActiveCell::new(); |
| 352 | active.push_tool( |
| 353 | "exec-1", |
| 354 | HistoryCell::Tool(ToolCell::Exec(ExecCell { |
| 355 | // A build, not a test run — `cargo test` would truthfully |
| 356 | // classify as the `verifying` phase (ShellPhase::Verifying). |
| 357 | command: "cargo build -p tui".to_string(), |
| 358 | status: ToolStatus::Running, |
| 359 | output: None, |
| 360 | live_output: None, |
| 361 | shell_task_id: None, |
| 362 | owner_agent_id: None, |
| 363 | owner_agent_name: None, |
| 364 | started_at: app.turn_started_at, |
| 365 | duration_ms: None, |
| 366 | stale_elapsed_since_output_ms: None, |
| 367 | source: ExecSource::Assistant, |
| 368 | interaction: None, |
| 369 | output_summary: None, |
| 370 | })), |
| 371 | ); |
| 372 | app.active_cell = Some(active); |
| 373 | |
| 374 | let backend = TestBackend::new(80, 1); |
| 375 | let mut terminal = Terminal::new(backend).expect("terminal"); |
| 376 | terminal |
| 377 | .draw(|frame| render(frame.area(), frame.buffer_mut(), &mut app)) |
| 378 | .expect("draw"); |
| 379 | let text = terminal |
| 380 | .backend() |
| 381 | .buffer() |
| 382 | .content() |
| 383 | .iter() |
| 384 | .map(|cell| cell.symbol()) |
| 385 | .collect::<String>(); |
| 386 | assert!(text.contains("using tool"), "{text}"); |
| 387 | assert!(text.contains("×1"), "{text}"); |
| 388 | assert!( |
| 389 | !text.contains("12s"), |
| 390 | "tool elapsed time belongs to the live tool row: {text}" |
| 391 | ); |
| 392 | assert!( |
| 393 | !text.contains("run ×1"), |
| 394 | "detail repeated the tool verb: {text}" |
| 395 | ); |
| 396 | assert!( |
| 397 | !text.contains("Alt+?") && !text.contains("F1:"), |
| 398 | "live phase strip stays quiet: {text}" |
| 399 | ); |
| 400 | assert!(text.contains("Esc to interrupt"), "{text}"); |
| 401 | } |
| 402 | |
| 403 | #[test] |
| 404 | fn compact_activity_band_keeps_only_the_semantic_label() { |
| 405 | let mut app = test_app(); |
| 406 | app.ui_locale = crate::localization::Locale::En; |
| 407 | app.turn_started_at = Some(Instant::now() - Duration::from_secs(12)); |
| 408 | let mut active = ActiveCell::new(); |
| 409 | active.push_tool( |
| 410 | "exec-compact", |
| 411 | HistoryCell::Tool(ToolCell::Exec(ExecCell { |
| 412 | command: "cargo build -p tui".to_string(), |
| 413 | status: ToolStatus::Running, |
| 414 | output: None, |
| 415 | live_output: None, |
| 416 | shell_task_id: None, |
| 417 | owner_agent_id: None, |
| 418 | owner_agent_name: None, |
| 419 | started_at: app.turn_started_at, |
| 420 | duration_ms: None, |
| 421 | stale_elapsed_since_output_ms: None, |
| 422 | source: ExecSource::Assistant, |
| 423 | interaction: None, |
| 424 | output_summary: None, |
| 425 | })), |
| 426 | ); |
| 427 | app.active_cell = Some(active); |
| 428 | |
| 429 | let backend = TestBackend::new(50, 1); |
| 430 | let mut terminal = Terminal::new(backend).expect("terminal"); |
| 431 | terminal |
| 432 | .draw(|frame| render(frame.area(), frame.buffer_mut(), &mut app)) |
| 433 | .expect("draw"); |
| 434 | let text = terminal |
| 435 | .backend() |
| 436 | .buffer() |
| 437 | .content() |
| 438 | .iter() |
| 439 | .map(|cell| cell.symbol()) |
| 440 | .collect::<String>(); |
| 441 | |
| 442 | assert!(text.contains("using tool"), "{text}"); |
| 443 | assert!( |
| 444 | !text.contains('×'), |
| 445 | "compact strip leaked count detail: {text}" |
| 446 | ); |
| 447 | assert!( |
| 448 | !text.contains("12s"), |
| 449 | "compact strip leaked timing detail: {text}" |
| 450 | ); |
| 451 | } |
| 452 | |
| 453 | #[test] |
| 454 | fn working_band_keeps_elapsed_time_when_model_is_thinking() { |
| 455 | let mut app = test_app(); |
| 456 | app.is_loading = true; |
| 457 | app.turn_started_at = Some(Instant::now() - Duration::from_secs(12)); |
| 458 | |
| 459 | assert_eq!( |
| 460 | working_detail(&app, LiveActivity::from_app(&app)).as_deref(), |
| 461 | Some("12s") |
| 462 | ); |
| 463 | |
| 464 | let backend = TestBackend::new(80, 1); |
| 465 | let mut terminal = Terminal::new(backend).expect("terminal"); |
| 466 | terminal |
| 467 | .draw(|frame| render(frame.area(), frame.buffer_mut(), &mut app)) |
| 468 | .expect("draw"); |
| 469 | let text = terminal |
| 470 | .backend() |
| 471 | .buffer() |
| 472 | .content() |
| 473 | .iter() |
| 474 | .map(|cell| cell.symbol()) |
| 475 | .collect::<String>(); |
| 476 | assert!(text.contains("Esc to interrupt"), "{text}"); |
| 477 | } |
| 478 | |
| 479 | #[test] |
| 480 | fn completed_band_keeps_unresolved_warning_visible() { |
| 481 | let mut app = test_app(); |
| 482 | app.runtime_turn_status = Some("completed".to_string()); |
| 483 | app.push_status_toast( |
| 484 | "Auto-denied exec_shell: denied earlier; restart Codewhale", |
| 485 | crate::tui::app::StatusToastLevel::Warning, |
| 486 | Some(12_000), |
| 487 | ); |
| 488 | |
| 489 | let backend = TestBackend::new(100, 1); |
| 490 | let mut terminal = Terminal::new(backend).expect("terminal"); |
| 491 | terminal |
| 492 | .draw(|frame| render(frame.area(), frame.buffer_mut(), &mut app)) |
| 493 | .expect("draw"); |
| 494 | let text = terminal |
| 495 | .backend() |
| 496 | .buffer() |
| 497 | .content() |
| 498 | .iter() |
| 499 | .map(|cell| cell.symbol()) |
| 500 | .collect::<String>(); |
| 501 | |
| 502 | assert!(text.contains("done"), "completion phase missing: {text}"); |
| 503 | assert!( |
| 504 | text.contains("Auto-denied exec_shell"), |
| 505 | "completion hid unresolved warning: {text}" |
| 506 | ); |
| 507 | } |
| 508 | |
| 509 | #[test] |
| 510 | fn cache_percentage_uses_wide_arithmetic_and_rounds() { |
| 511 | let mut app = test_app(); |
| 512 | assert_eq!(session_cache_hit_percentage(&app), None); |
| 513 | |
| 514 | app.session.total_cache_hit_tokens = 2; |
| 515 | app.session.total_cache_miss_tokens = 1; |
| 516 | assert_eq!(session_cache_hit_percentage(&app), Some(67)); |
| 517 | |
| 518 | app.session.total_cache_hit_tokens = u32::MAX; |
| 519 | app.session.total_cache_miss_tokens = u32::MAX; |
| 520 | assert_eq!(session_cache_hit_percentage(&app), Some(50)); |
| 521 | } |
| 522 | |
| 523 | #[test] |
| 524 | fn cache_chip_is_labeled_configurable_and_hidden_when_compact() { |
| 525 | let mut app = test_app(); |
| 526 | app.status_items = vec![crate::config::StatusItem::Cache]; |
| 527 | app.session.total_cache_hit_tokens = 7; |
| 528 | app.session.total_cache_miss_tokens = 3; |
| 529 | |
| 530 | let backend = TestBackend::new(80, 1); |
| 531 | let mut terminal = Terminal::new(backend).expect("terminal"); |
| 532 | terminal |
| 533 | .draw(|frame| render(frame.area(), frame.buffer_mut(), &mut app)) |
| 534 | .expect("draw"); |
| 535 | let text = terminal |
| 536 | .backend() |
| 537 | .buffer() |
| 538 | .content() |
| 539 | .iter() |
| 540 | .map(|cell| cell.symbol()) |
| 541 | .collect::<String>(); |
| 542 | assert!(text.contains("cache 70%"), "{text}"); |
| 543 | |
| 544 | app.status_items.clear(); |
| 545 | terminal |
| 546 | .draw(|frame| render(frame.area(), frame.buffer_mut(), &mut app)) |
| 547 | .expect("draw without cache"); |
| 548 | let text = terminal |
| 549 | .backend() |
| 550 | .buffer() |
| 551 | .content() |
| 552 | .iter() |
| 553 | .map(|cell| cell.symbol()) |
| 554 | .collect::<String>(); |
| 555 | assert!(!text.contains("cache"), "{text}"); |
| 556 | |
| 557 | app.status_items = vec![crate::config::StatusItem::Cache]; |
| 558 | let backend = TestBackend::new(50, 1); |
| 559 | let mut compact = Terminal::new(backend).expect("compact terminal"); |
| 560 | compact |
| 561 | .draw(|frame| render(frame.area(), frame.buffer_mut(), &mut app)) |
| 562 | .expect("compact draw"); |
| 563 | let text = compact |
| 564 | .backend() |
| 565 | .buffer() |
| 566 | .content() |
| 567 | .iter() |
| 568 | .map(|cell| cell.symbol()) |
| 569 | .collect::<String>(); |
| 570 | assert!(!text.contains("cache"), "compact strip: {text}"); |
| 571 | } |
| 572 | } |
| 573 |