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