返回 CodeWhale
phase_strip.rs
根目录 / crates / tui / src / tui / phase_strip.rs
1 //! The posture bar and the route-identity shedding it shares with the
2 //! metrics line.
3 //!
4 //! Two one-row bands sit under the composer and never trade places with
5 //! it: the **posture bar** (this module's widget — permission, mode, live
6 //! counts, the one hint that applies now, with the remote-control state or
7 //! a live notice pinned right) and the **metrics line**
8 //! (`crate::tui::infoline` — model, context, cost, ttft, tok/s, output
9 //! tokens). Both rows are reserved in every frame, so a turn moving between
10 //! idle, thinking, tool use, approval, completion, failure, and cancellation
11 //! changes text inside fixed rows and never displaces the composer.
12 //!
13 //! One owner per fact: the context reading and the price are the metrics
14 //! line's; mode, permission and the working clock are this bar's. The module
15 //! name is the historical one — the phase word it painted also lives in the
16 //! transcript's active row, but the bar keeps its own copy beside the clock
17 //! (#5914): a bare duration cannot say whether the session is producing
18 //! tokens or parked waiting on a tool, a sub-agent, or you.
19
20 use ratatui::{
21 buffer::Buffer,
22 layout::Rect,
23 style::{Modifier, Style},
24 };
25 use unicode_width::UnicodeWidthStr;
26
27 use crate::tui::{
28 app::App,
29 underwater::{LiveActivity, ShellPhase, ShellTier, phase_marker_with_activity},
30 };
31 use codewhale_localization::{MessageId, tr};
32 use codewhale_palette::ChromeInk;
33
34 /// Fixed one-row reservation for the identity band below the composer.
35 #[must_use]
36 pub fn height() -> u16 {
37 1
38 }
39
40 /// Route identity for a rail or info line segment, shed field by field until it
41 /// fits `budget`.
42 ///
43 /// The old version composed the full `provider · model · effort` label and
44 /// then `truncate_to_width`'d it to a fixed 24/44/64 columns, which happily
45 /// rendered `deepseek-v4-flash-prev…`. A clipped model name is worse than no
46 /// model name: routes share prefixes, so the ellipsis is the rail admitting
47 /// it will not tell you which model is answering. Shed the qualifiers
48 /// instead — provider first, then effort — and if the bare model name still
49 /// does not fit, shed the whole group. `/model` and `/status` own the full
50 /// route either way.
51 pub(crate) fn route_identity_fields(
52 app: &App,
53 tier: ShellTier,
54 budget: usize,
55 ) -> Option<Vec<RouteIdentityField>> {
56 let (provider, model) = app.effective_route_identity_display();
57 // A route that cannot prove its effective tier states no effort field
58 // rather than `high→effective unavailable` (#5950): a placeholder that
59 // can never resolve is noise, not a reading. First-party routes keep
60 // their tier, `auto: tier` and `req→eff` labels.
61 let effort = app.provable_reasoning_effort_label().unwrap_or_default();
62 if model.is_empty() {
63 return None;
64 }
65 let field = |kind, text: String| RouteIdentityField { kind, text };
66 let mut candidates: Vec<Vec<RouteIdentityField>> = Vec::new();
67 if tier != ShellTier::Compact && !provider.is_empty() {
68 // The smallest shell never repeats the provider: model and effort are
69 // the two facts that change what comes back.
70 let mut fields = vec![
71 field(RouteFieldKind::Provider, provider),
72 field(RouteFieldKind::Model, model.clone()),
73 ];
74 if !effort.is_empty() {
75 fields.push(field(RouteFieldKind::Effort, effort.clone()));
76 }
77 candidates.push(fields);
78 }
79 if !effort.is_empty() {
80 candidates.push(vec![
81 field(RouteFieldKind::Model, model.clone()),
82 field(RouteFieldKind::Effort, effort),
83 ]);
84 }
85 candidates.push(vec![field(RouteFieldKind::Model, model)]);
86 candidates.into_iter().find(|fields| {
87 let width = fields.iter().map(|field| field.text.width()).sum::<usize>()
88 + fields.len().saturating_sub(1) * ITEM_SEPARATOR_WIDTH;
89 width <= budget
90 })
91 }
92
93 /// Which route fact a rendered field is. The info line needs this to send a
94 /// click to the surface that owns the fact the user pointed at: the provider
95 /// name to `/provider`, the model and its effort tier to `/model`.
96 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
97 pub(crate) enum RouteFieldKind {
98 Provider,
99 Model,
100 Effort,
101 }
102
103 /// One rendered route field: what it says, and what it is.
104 #[derive(Debug, Clone, PartialEq, Eq)]
105 pub(crate) struct RouteIdentityField {
106 pub kind: RouteFieldKind,
107 pub text: String,
108 }
109
110 /// The info line's route budget: the row's width less the brand lockup,
111 /// meter, and clock floor, never below 24.
112 ///
113 /// One owner. The rule used to be written out at the call site in
114 /// `ui/frame.rs` and copied again into two tests with a comment pointing
115 /// back at the original, which is how a shed rule drifts.
116 pub(crate) fn info_route_budget(width: u16) -> usize {
117 usize::from(width).saturating_sub(60).max(24)
118 }
119
120 /// Split a notice at its joints, coarsest first.
121 ///
122 /// A rail notice is prose, and prose has joints. Cutting at a joint keeps
123 /// every word that survives true; cutting mid-phrase and hanging an ellipsis
124 /// off the end only advertises that the row lost the argument. Sentence stops
125 /// are the joint we want; the inner marks are the fallback for a one-sentence
126 /// notice that is still too long for a narrow rail — losing the second half
127 /// of `Auto-denied exec_shell: denied earlier` beats losing the warning.
128 fn notice_clauses<'a>(text: &'a str, marks: &[char]) -> Vec<&'a str> {
129 let mut clauses = Vec::new();
130 let mut start = 0usize;
131 let mut chars = text.char_indices().peekable();
132 while let Some((idx, ch)) = chars.next() {
133 if !marks.contains(&ch) {
134 continue;
135 }
136 // Full-width marks carry no trailing space, so they break on sight.
137 // ASCII marks only break before whitespace, which keeps `0.9.11`,
138 // `docs/TELEMETRY.md`, and `https://…` in one piece.
139 let breaks = !ch.is_ascii() || chars.peek().is_none_or(|(_, next)| next.is_whitespace());
140 if !breaks {
141 continue;
142 }
143 // `1.` opening a numbered step is a list ordinal, not a sentence
144 // stop — breaking there leaves the toast ending on a bare `1.`
145 // (the send-blocked clip: `…not found. 1.`). Only the pure
146 // number-and-stop shape is exempt; `Version 1.` still ends a clause.
147 if ch == '.'
148 && text[start..idx].trim().bytes().all(|b| b.is_ascii_digit())
149 && !text[start..idx].trim().is_empty()
150 {
151 continue;
152 }
153 let end = idx + ch.len_utf8();
154 let clause = text[start..end].trim();
155 if !clause.is_empty() {
156 clauses.push(clause);
157 }
158 start = end;
159 }
160 let rest = text[start..].trim();
161 if !rest.is_empty() {
162 clauses.push(rest);
163 }
164 clauses
165 }
166
167 /// Sentence stops — the joint a notice prefers to be cut at.
168 const SENTENCE_MARKS: [char; 7] = ['.', '!', '?', '…', '。', '!', '?'];
169 /// Inner joints, used only when one sentence still will not fit the rail.
170 const CLAUSE_MARKS: [char; 8] = [';', ':', ',', '—', ';', ':', ',', '、'];
171
172 fn join_while_fitting(clauses: &[&str], budget: usize) -> Option<String> {
173 let mut fitted = String::new();
174 for clause in clauses {
175 // A full-width stop already carries its own breathing room; putting
176 // a Latin space after `。` is a typographic accent in the wrong
177 // language.
178 let space = usize::from(!fitted.is_empty() && fitted.ends_with(|ch: char| ch.is_ascii()));
179 let candidate = fitted.width() + space + clause.width();
180 if candidate > budget {
181 break;
182 }
183 if space == 1 {
184 fitted.push(' ');
185 }
186 fitted.push_str(clause);
187 }
188 // A phrase that ends on `:` or `;` is still telling you more is coming —
189 // the same lie an ellipsis tells. Cut the mark and let the phrase stand.
190 let fitted = fitted
191 .trim_end_matches(|ch| CLAUSE_MARKS.contains(&ch) || ch == ' ')
192 .to_string();
193 (!fitted.is_empty()).then_some(fitted)
194 }
195
196 /// Fit a notice into `budget` by dropping whole trailing clauses.
197 ///
198 /// Returns `None` only when not even the first inner phrase fits, and the
199 /// rail then says nothing rather than dangling a stump. Notices get first
200 /// call on the row: identity and the ledger chips have already stood down by
201 /// the time this is asked, and the key hints stand down after it if that is
202 /// what the notice needs.
203 fn fit_notice(text: &str, budget: usize) -> Option<String> {
204 let text = text.trim();
205 if text.is_empty() || budget == 0 {
206 return None;
207 }
208 if text.width() <= budget {
209 return Some(text.to_string());
210 }
211 let sentences = notice_clauses(text, &SENTENCE_MARKS);
212 if let Some(fitted) = join_while_fitting(&sentences, budget) {
213 return Some(fitted);
214 }
215 let first = sentences.first().copied().unwrap_or(text);
216 join_while_fitting(&notice_clauses(first, &CLAUSE_MARKS), budget)
217 }
218
219 /// Map the boot surface's typed severity through the same semantic palette as
220 /// every other footer fact. Keeping this conversion closed makes the plugin
221 /// warning/failure distinction testable without guessing from its text.
222 fn boot_activity_ink(level: crate::tui::session_boot::SessionBootActivityLevel) -> ChromeInk {
223 match level {
224 crate::tui::session_boot::SessionBootActivityLevel::Active => ChromeInk::Active,
225 crate::tui::session_boot::SessionBootActivityLevel::Attention => ChromeInk::Attention,
226 crate::tui::session_boot::SessionBootActivityLevel::Failure => ChromeInk::Failure,
227 }
228 }
229
230 /// Pick the notice a band owes its row to right now, if any. Shared by the
231 /// classic activity band and the Tideline merged footer so the two can never
232 /// disagree about which toast is live. Completion may land in the same event
233 /// drain as an approval denial: unresolved Warning/Error receipts stay
234 /// visible after `done`, only routine informational copy yields.
235 fn selected_notice(
236 status_toast: Option<crate::tui::app::StatusToast>,
237 phase_label: &str,
238 ) -> Option<(String, ChromeInk, bool)> {
239 status_toast
240 .filter(|toast| !toast.text.trim().is_empty() && toast.text.trim() != phase_label)
241 .map(|toast| {
242 let urgent = matches!(
243 toast.level,
244 crate::tui::app::StatusToastLevel::Warning
245 | crate::tui::app::StatusToastLevel::Error
246 );
247 (toast.text.clone(), toast.level.ink(), urgent)
248 })
249 }
250
251 /// Separate footer groups with breathing room; provider/model fields keep
252 /// their internal middle dots in the identity row.
253 const ITEM_SEPARATOR: &str = " ";
254 const ITEM_SEPARATOR_WIDTH: usize = 3;
255
256 #[cfg(test)]
257 mod tests {
258 use super::*;
259 use crate::{config::Config, tui::app::TuiOptions};
260 use std::path::PathBuf;
261
262 fn test_app() -> App {
263 App::new(
264 TuiOptions {
265 model: "deepseek-v4-flash".to_string(),
266 ..crate::test_support::test_tui_options(PathBuf::from("."))
267 },
268 &Config::default(),
269 )
270 }
271
272 #[test]
273 fn done_footer_preserves_unresolved_notice_behind_later_routine_info() {
274 use crate::tui::app::StatusToastLevel;
275 for (level, ink) in [
276 (StatusToastLevel::Warning, ChromeInk::Attention),
277 (StatusToastLevel::Error, ChromeInk::Failure),
278 ] {
279 let mut app = test_app();
280 app.runtime_turn_status = Some("completed".into());
281 app.push_status_toast("Unresolved issue", level, Some(12_000));
282 app.push_status_toast("Routine update", StatusToastLevel::Info, Some(5_000));
283 assert_eq!(ShellPhase::from_app(&app), ShellPhase::Done);
284 assert!(
285 app.history.is_empty(),
286 "the transcript must not satisfy this fixture"
287 );
288 let facts = tideline_footer_from_app(&mut app, 140);
289 assert_eq!(facts.right, Some(("Unresolved issue".into(), ink)));
290 let mut buf = Buffer::empty(Rect::new(0, 0, 140, 1));
291 render_tideline_footer(
292 Rect::new(0, 0, 140, 1),
293 &mut buf,
294 &facts.widget(&app.ui_theme, false),
295 );
296 let text: String = buf.content.iter().map(|cell| cell.symbol()).collect();
297 assert!(text.contains("Unresolved issue"), "{text}");
298 assert!(!text.contains("Routine update"));
299 }
300 }
301
302 #[test]
303 fn boot_activity_levels_keep_plugin_attention_and_failure_distinct() {
304 use crate::tui::session_boot::SessionBootActivityLevel;
305
306 assert_eq!(
307 boot_activity_ink(SessionBootActivityLevel::Active),
308 ChromeInk::Active
309 );
310 assert_eq!(
311 boot_activity_ink(SessionBootActivityLevel::Attention),
312 ChromeInk::Attention
313 );
314 assert_eq!(
315 boot_activity_ink(SessionBootActivityLevel::Failure),
316 ChromeInk::Failure
317 );
318 }
319
320 #[test]
321 fn notice_clauses_split_on_sentences_and_keep_versions_and_paths_whole() {
322 assert_eq!(
323 notice_clauses(
324 "Counts are on. Code is never collected. See docs/T.md",
325 &SENTENCE_MARKS
326 ),
327 vec![
328 "Counts are on.",
329 "Code is never collected.",
330 "See docs/T.md"
331 ]
332 );
333 assert_eq!(
334 notice_clauses("Updated to 0.9.11 from 0.9.10", &SENTENCE_MARKS),
335 vec!["Updated to 0.9.11 from 0.9.10"]
336 );
337 // Full-width stops carry no trailing space, so they break on sight.
338 assert_eq!(
339 notice_clauses(
340 "匿名の利用回数は有効です。会話とコードは収集されません。",
341 &SENTENCE_MARKS
342 ),
343 vec![
344 "匿名の利用回数は有効です。",
345 "会話とコードは収集されません。"
346 ]
347 );
348 // A colon inside a URL is not a joint.
349 assert_eq!(
350 notice_clauses("Docs: https://example.test/x", &CLAUSE_MARKS),
351 vec!["Docs:", "https://example.test/x"]
352 );
353 }
354
355 #[test]
356 fn shed_clauses_rejoin_without_a_latin_space_after_a_full_width_stop() {
357 const JA: &str = "匿名の利用状況集計はオンです。会話やコードは一切収集しません。/settings で変更できます。スキーマ: docs/TELEMETRY.md";
358 let clauses = notice_clauses(JA, &SENTENCE_MARKS);
359 let joined = join_while_fitting(&clauses, 200).expect("fits");
360 assert!(!joined.contains("。 "), "{joined:?}");
361 assert!(joined.ends_with("docs/TELEMETRY.md"), "{joined:?}");
362 }
363
364 #[test]
365 fn a_notice_sheds_whole_clauses_and_never_dangles() {
366 const NOTICE: &str = "Anonymous usage counts are on. Conversations and code are never collected. Change this in /settings; schema: docs/TELEMETRY.md";
367 assert_eq!(fit_notice(NOTICE, 200).as_deref(), Some(NOTICE));
368 assert_eq!(
369 fit_notice(NOTICE, 80).as_deref(),
370 Some("Anonymous usage counts are on. Conversations and code are never collected.")
371 );
372 assert_eq!(
373 fit_notice(NOTICE, 40).as_deref(),
374 Some("Anonymous usage counts are on.")
375 );
376 assert_eq!(fit_notice(" ", 40), None);
377 }
378
379 /// The failure this caught: a one-sentence warning longer than the row
380 /// used to have no sentence joint to shed at, so the rail dropped the
381 /// whole warning. Inner joints are the fallback, and the phrase that
382 /// survives never ends on a `:` or `;` — that mark says "more is coming"
383 /// as loudly as an ellipsis does.
384 #[test]
385 fn a_clause_less_warning_sheds_at_inner_joints_rather_than_vanishing() {
386 const WARNING: &str =
387 "Auto-denied exec_shell: denied earlier; restart Codewhale to re-enable it.";
388 assert_eq!(fit_notice(WARNING, 120).as_deref(), Some(WARNING));
389 assert_eq!(
390 fit_notice(WARNING, 60).as_deref(),
391 Some("Auto-denied exec_shell: denied earlier")
392 );
393 assert_eq!(
394 fit_notice(WARNING, 30).as_deref(),
395 Some("Auto-denied exec_shell")
396 );
397 }
398
399 #[test]
400 fn session_metrics_strip_is_on_by_default() {
401 assert!(
402 crate::config::StatusItem::default_footer().contains(&crate::config::StatusItem::Ttft)
403 && crate::config::StatusItem::default_footer()
404 .contains(&crate::config::StatusItem::OutputRate)
405 );
406 assert_eq!(
407 crate::config::StatusItem::from_key("session_metrics"),
408 Some(crate::config::StatusItem::SessionMetrics)
409 );
410 }
411
412 /// The route identity (the info line Model segment's value) sheds whole
413 /// fields — provider first, then the effort label — and stands down
414 /// entirely rather than clip a model name. Ported from the identity
415 /// band to `route_identity_fields`, the live shedding authority the
416 /// info line calls with the same budget rule.
417 #[test]
418 fn route_identity_sheds_qualifiers_before_it_would_clip_a_model_name() {
419 let model = "deepseek-v4-flash-preview-2026-05-01";
420 let mut app = App::new(
421 TuiOptions {
422 model: model.to_string(),
423 ..crate::test_support::test_tui_options(PathBuf::from("."))
424 },
425 &Config::default(),
426 );
427 app.ui_locale = codewhale_localization::Locale::En;
428
429 // The info line's own budget rule (ui/frame.rs): width minus the brand
430 // lockup, meter, and clock floor, never below 24.
431 let fields = |width: u16| {
432 route_identity_fields(
433 &app,
434 ShellTier::for_chrome_width(width),
435 info_route_budget(width),
436 )
437 };
438
439 let wide = fields(140).expect("wide budget keeps the route");
440 assert!(
441 wide.iter().any(|f| f.text.contains("DeepSeek"))
442 && wide.iter().any(|f| f.text == model),
443 "{wide:?}"
444 );
445
446 // Below the group's width the provider sheds first; the model stays
447 // whole or the whole group stands down — never a clipped name.
448 for width in [30u16, 34, 40, 46, 50, 60] {
449 let shed = fields(width).unwrap_or_default();
450 for field in &shed {
451 assert!(
452 !field.text.contains('…'),
453 "{width} dangled a clipped field: {shed:?}"
454 );
455 }
456 if shed.iter().any(|f| f.text.contains("deepseek-v4-flash-p")) {
457 assert!(
458 shed.iter().any(|f| f.text == model),
459 "{width} clipped the model name: {shed:?}"
460 );
461 }
462 }
463 }
464
465 /// A named custom route can carry a long provider identity next to a
466 /// long model id; whole fields shed (provider first, effort label next)
467 /// and neither name is ever clipped. Ported from the identity band to
468 /// `route_identity_fields`.
469 #[test]
470 fn unproven_effort_keeps_named_provider_when_the_route_fits() {
471 let mut app = test_app();
472 app.set_provider_identity(crate::config::ApiProvider::Custom, "lab-gateway");
473 app.model = "unlisted-model".to_string();
474 assert!(app.provable_reasoning_effort_label().is_none());
475 let fields = route_identity_fields(&app, ShellTier::for_chrome_width(160), 100).unwrap();
476 assert_eq!(
477 fields.iter().map(|field| field.kind).collect::<Vec<_>>(),
478 vec![RouteFieldKind::Provider, RouteFieldKind::Model]
479 );
480 assert_eq!(fields[0].text, "lab-gateway");
481 assert_eq!(fields[1].text, "unlisted-model");
482 let compact = route_identity_fields(&app, ShellTier::Compact, 100).unwrap();
483 assert_eq!(compact.len(), 1);
484 assert_eq!(compact[0].kind, RouteFieldKind::Model);
485 let narrow = route_identity_fields(&app, ShellTier::for_chrome_width(160), 14).unwrap();
486 assert_eq!(narrow.len(), 1);
487 assert_eq!(narrow[0].text, "unlisted-model");
488 }
489
490 #[test]
491 fn long_custom_route_names_shed_whole_fields_across_width_tiers() {
492 let model = "deepseek-v4-flash-vision-preview-2026-08-01";
493 let mut app = test_app();
494 app.ui_locale = codewhale_localization::Locale::En;
495 app.set_provider_identity(
496 crate::config::ApiProvider::Custom,
497 "acme-research-gateway-eu-central",
498 );
499 app.model = model.to_string();
500
501 for width in [30u16, 40, 50, 60, 70, 80, 160] {
502 let shed = route_identity_fields(
503 &app,
504 ShellTier::for_chrome_width(width),
505 info_route_budget(width),
506 )
507 .unwrap_or_default();
508 for field in &shed {
509 assert!(
510 !field.text.contains('…'),
511 "{width} dangled a clipped field: {shed:?}"
512 );
513 }
514 if shed
515 .iter()
516 .any(|f| f.text.contains("deepseek-v4-flash-vision"))
517 {
518 assert!(
519 shed.iter().any(|f| f.text == model),
520 "{width} clipped the model name: {shed:?}"
521 );
522 }
523 assert!(
524 !shed
525 .iter()
526 .any(|f| f.text.contains("acme-research-gateway-eu-c")
527 && f.text != "acme-research-gateway-eu-central"),
528 "{width} clipped the provider name: {shed:?}"
529 );
530 }
531 }
532 }
533
534 // ---------------------------------------------------------------------------
535 // Tideline merged footer (spec §3 slots 6+8 merged, §5a "Footer"): one
536 // band — phase·cost on the left, the notice/keys slot on the right.
537 // Wired into `ui/frame.rs` as the shell's single footer row: the classic
538 // activity band (slot 6) and identity band (slot 8) collapsed into it, with
539 // the old header's mode/permission chips carried in the left half per §3.
540 // ---------------------------------------------------------------------------
541 // The posture bar (SHELL-DESIGN-20260901 §2.0 item 3, §2.3b; founder
542 // direction 2026-09-02): the first row under the composer, in Claude Code's
543 // grammar —
544 //
545 // full access (Shift+Tab) work (Tab) 2 agents, 1 task Esc to interrupt rc connected
546 //
547 // permission chip first (never sheds, #5796), the mode, the turn clock, the
548 // live counts, the session clock, then the one hint that applies right now;
549 // the remote-control state or a live notice pinned right. No cost: the
550 // roster owns per-agent elapsed and the metrics line owns the price. The
551 // context reading is the metrics line's; this row only says what to do about
552 // it at the cap.
553 //
554 // The clock came back in #5914. It was the `worked Nm Ss` chip on the
555 // classic footer until `146ab7f756` deleted that path, and the phase band's
556 // `working_detail` until `329960fcbf` (the 0.9.12 mega shell) merged the
557 // bands and left the elapsed reading to the transcript's active row. A
558 // multi-hour operate session scrolls that row out of sight, so the founder
559 // looking straight at the screen had no way to tell how long the session had
560 // been working or whether the current turn was stuck. The fixed row is where
561 // a glancing user looks; the clock lives here.
562 // ---------------------------------------------------------------------------
563
564 /// The context cap warning at ≥80% (spec §5a/§5e). The reading itself lives
565 /// in the metrics line; this bar still says what to do about it.
566 const DEPTH_WARN: &str = "surface soon — /compact";
567
568 /// Inside the counts group (`2 agents, 1 task`).
569 const COUNT_SEPARATOR: &str = ", ";
570
571 /// What the caller owes the posture bar. All injected, deterministic.
572 pub struct TidelineFooter<'a> {
573 pub theme: &'a codewhale_palette::UiTheme,
574 /// Permission chip (`ask` / `auto` / `full access`, plus the filesystem
575 /// scope notice when it deviates) in its Permission ink. Never sheds.
576 pub permission_chip: (&'a str, codewhale_palette::ChromeInk),
577 /// The chord that cycles the permission posture, when the binding is
578 /// live for the current focus (`Shift+Tab`).
579 pub permission_key: Option<&'a str>,
580 /// Mode chip (`work` / `plan` / `operate`) in its Policy ink.
581 pub mode_chip: Option<(&'a str, codewhale_palette::ChromeInk)>,
582 /// The chord that cycles the mode, when the binding is live (`Tab`).
583 pub mode_key: Option<&'a str>,
584 /// The turn half of the working clock (`working 1m 15s`): what the
585 /// session is doing right now and for how long. `None` between turns.
586 pub turn_clock: Option<(&'a str, codewhale_palette::ChromeInk)>,
587 /// Live counts (`2 agents`, `1 task`) in their own inks, joined with
588 /// `, `.
589 pub counts: &'a [(String, ChromeInk)],
590 /// The session half of the working clock (`worked 41m 12s`): how long
591 /// this session has actually worked — the reading the founder went
592 /// looking for and could not find (#5914). Outlives the turn half, and
593 /// sheds before the hint and the counts. `None` until the session has
594 /// worked a minute, and while it would repeat the turn reading (#6041).
595 pub session_clock: Option<(&'a str, codewhale_palette::ChromeInk)>,
596 /// The one hint that applies right now (`Esc to interrupt`).
597 pub hint: Option<(&'a str, codewhale_palette::ChromeInk)>,
598 /// Context window percentage 0–100. The metrics line paints the reading;
599 /// this bar only uses it to decide whether the ≥80% cap warning outranks
600 /// `hint`.
601 pub context_percent: u8,
602 /// Pinned right: a live notice (status toast / boot activity chip) or
603 /// the remote-control state.
604 pub right: Option<(&'a str, codewhale_palette::ChromeInk)>,
605 pub ascii_safe: bool,
606 /// `tui.posture_bar = "compact"` (#5950): start the shed ladder at
607 /// [`COMPACT_SHED`] instead of rung 0, so the row states its posture —
608 /// the permission and mode chips, and the cap warning when it is owed —
609 /// and nothing live. Width sheds the rest exactly as it always did.
610 pub compact: bool,
611 }
612
613 impl<'a> TidelineFooter<'a> {
614 #[must_use]
615 pub fn new(
616 theme: &'a codewhale_palette::UiTheme,
617 permission_chip: (&'a str, codewhale_palette::ChromeInk),
618 ) -> Self {
619 Self {
620 theme,
621 permission_chip,
622 permission_key: None,
623 mode_chip: None,
624 mode_key: None,
625 turn_clock: None,
626 counts: &[],
627 session_clock: None,
628 hint: None,
629 context_percent: 0,
630 right: None,
631 ascii_safe: false,
632 compact: false,
633 }
634 }
635
636 #[must_use]
637 pub fn permission_key(mut self, key: Option<&'a str>) -> Self {
638 self.permission_key = key;
639 self
640 }
641
642 #[must_use]
643 pub fn mode_chip(mut self, chip: Option<(&'a str, codewhale_palette::ChromeInk)>) -> Self {
644 self.mode_chip = chip;
645 self
646 }
647
648 #[must_use]
649 pub fn mode_key(mut self, key: Option<&'a str>) -> Self {
650 self.mode_key = key;
651 self
652 }
653
654 #[must_use]
655 pub fn turn_clock(mut self, clock: Option<(&'a str, codewhale_palette::ChromeInk)>) -> Self {
656 self.turn_clock = clock;
657 self
658 }
659
660 #[must_use]
661 pub fn session_clock(mut self, clock: Option<(&'a str, codewhale_palette::ChromeInk)>) -> Self {
662 self.session_clock = clock;
663 self
664 }
665
666 #[must_use]
667 pub fn counts(mut self, counts: &'a [(String, ChromeInk)]) -> Self {
668 self.counts = counts;
669 self
670 }
671
672 #[must_use]
673 pub fn hint(mut self, hint: Option<(&'a str, codewhale_palette::ChromeInk)>) -> Self {
674 self.hint = hint;
675 self
676 }
677
678 #[must_use]
679 pub fn context_percent(mut self, percent: u8) -> Self {
680 self.context_percent = percent;
681 self
682 }
683
684 #[must_use]
685 pub fn right(mut self, right: Option<(&'a str, codewhale_palette::ChromeInk)>) -> Self {
686 self.right = right;
687 self
688 }
689
690 #[must_use]
691 pub fn ascii_safe(mut self, ascii_safe: bool) -> Self {
692 self.ascii_safe = ascii_safe;
693 self
694 }
695
696 #[must_use]
697 pub fn compact(mut self, compact: bool) -> Self {
698 self.compact = compact;
699 self
700 }
701
702 /// The rung the shed ladder starts from: 0 for a full row, past the
703 /// clocks, hint and counts for a compact one.
704 fn first_shed_rung(&self) -> u8 {
705 if self.compact { COMPACT_SHED } else { 0 }
706 }
707
708 fn sym(&self, glyph: &str) -> String {
709 if !self.ascii_safe {
710 return glyph.to_string();
711 }
712 if let Some(fb) = crate::tui::glyphs::ascii_fallback(glyph) {
713 return fb.to_string();
714 }
715 glyph
716 .chars()
717 .map(|c| {
718 crate::tui::glyphs::ascii_fallback(&c.to_string())
719 .map(str::to_string)
720 .unwrap_or_else(|| c.to_string())
721 })
722 .collect()
723 }
724
725 /// Whether the context window is full enough that the bar owes the cap
726 /// warning. It replaces the hint and, unlike a hint, outranks the clock
727 /// and the counts on the shed ladder.
728 fn at_context_cap(&self) -> bool {
729 self.context_percent.clamp(0, 100) >= 80
730 }
731
732 /// The hint the left run ends on: the cap warning outranks whatever the
733 /// caller passed, because a full context is the one thing that stops the
734 /// next turn.
735 fn effective_hint(&self) -> Option<(String, ChromeInk)> {
736 if self.at_context_cap() {
737 return Some((
738 format!("{} {}", self.sym("▲"), self.sym(DEPTH_WARN)),
739 ChromeInk::Attention,
740 ));
741 }
742 self.hint.map(|(text, ink)| (self.sym(text), ink))
743 }
744 }
745
746 fn tchrome(theme: &codewhale_palette::UiTheme, ink: codewhale_palette::ChromeInk) -> Style {
747 codewhale_palette::grammar::chrome_style(theme, ink)
748 }
749
750 fn tput(buf: &mut Buffer, x: u16, y: u16, text: &str, style: Style) {
751 buf.set_stringn(x, y, text, text.width(), style);
752 }
753
754 /// One painted item of the left run: text, ink, and whether it is a chip
755 /// (bold) or a hint.
756 struct PostureItem {
757 text: String,
758 ink: ChromeInk,
759 bold: bool,
760 /// Painted after `, ` rather than ` · `: the counts are one group.
761 joined: bool,
762 /// Which of `footer.counts` this item is, when it is one.
763 count_index: Option<usize>,
764 }
765
766 /// Shed rungs for the left run, most expendable first. The permission chip
767 /// itself has no rung: it never sheds (#5796), because a silently missing
768 /// `full access` is the bar under-reporting the authority the session
769 /// actually holds.
770 ///
771 /// The two halves of the working clock shed apart, and both go first
772 /// (#5914). The clock is what a *glance* wants; at the narrowest widths the
773 /// row's other facts are what a *keystroke* wants — the counts name work you
774 /// can open, the hint names a chord you can press right now (`Esc to
775 /// interrupt`, `Enter again to send now`). An 80-column row carrying the
776 /// filesystem-scope notice cannot hold all of it, and losing the affordance
777 /// to keep the stopwatch is the wrong trade. When both halves would paint,
778 /// the turn half goes before the session half: the transcript's active row
779 /// and the spinner also show the turn is alive, while the session total is
780 /// stated nowhere else. When the session half is suppressed (#6041) or
781 /// otherwise absent, the turn half sheds at the session-clock rung instead
782 /// so the ladder does not abandon the only clock at the turn-only rung
783 /// while a both-clocks row would still be stating a stopwatch (#6084).
784 /// The hint and counts still outrank it (#5914). Above them the
785 /// context-cap warning, which is not a hint but the reason the next turn
786 /// will not start at all.
787 const SHED_TURN_CLOCK: u8 = 1;
788 const SHED_SESSION_CLOCK: u8 = 2;
789 const SHED_HINT: u8 = 3;
790 const SHED_COUNTS: u8 = 4;
791 const SHED_CAP_WARNING: u8 = 5;
792 const SHED_MODE_KEY: u8 = 6;
793 const SHED_MODE: u8 = 7;
794 const SHED_PERMISSION_KEY: u8 = 8;
795 /// The most-shed rung: everything gone but the permission chip.
796 const MAX_SHED: u8 = SHED_PERMISSION_KEY;
797 /// Where a compact posture bar (`tui.posture_bar = "compact"`, #5950)
798 /// starts on the ladder: the clocks, the hint and the counts are gone
799 /// before width is consulted; the cap warning, the mode chip and the
800 /// permission chip — the row's posture — stay and shed only by width.
801 const COMPACT_SHED: u8 = SHED_COUNTS;
802
803 fn posture_items(footer: &TidelineFooter<'_>, shed: u8) -> Vec<PostureItem> {
804 let chip = |text: &str, key: Option<&str>| -> String {
805 match key {
806 Some(key) => format!("{text} ({key})"),
807 None => text.to_string(),
808 }
809 };
810 let mut items = vec![PostureItem {
811 text: chip(
812 &footer.sym(footer.permission_chip.0),
813 footer.permission_key.filter(|_| shed < SHED_PERMISSION_KEY),
814 ),
815 ink: footer.permission_chip.1,
816 bold: true,
817 joined: false,
818 count_index: None,
819 }];
820 if let Some((mode, ink)) = footer.mode_chip.filter(|_| shed < SHED_MODE) {
821 items.push(PostureItem {
822 text: chip(
823 &footer.sym(mode),
824 footer.mode_key.filter(|_| shed < SHED_MODE_KEY),
825 ),
826 ink,
827 bold: false,
828 joined: false,
829 count_index: None,
830 });
831 }
832 // When no session half will paint, shed the turn clock at the session
833 // rung so a width that would keep the session half (and drop the turn)
834 // still keeps the only informative clock (#6084). Hint and counts still
835 // outrank it (#5914). With both halves present the turn half still goes
836 // first.
837 let turn_shed = if footer.session_clock.is_none() {
838 SHED_SESSION_CLOCK
839 } else {
840 SHED_TURN_CLOCK
841 };
842 if let Some((clock, ink)) = footer.turn_clock.filter(|_| shed < turn_shed) {
843 items.push(PostureItem {
844 text: footer.sym(clock),
845 ink,
846 bold: false,
847 joined: false,
848 count_index: None,
849 });
850 }
851 if shed < SHED_COUNTS {
852 for (index, (count, ink)) in footer.counts.iter().enumerate() {
853 items.push(PostureItem {
854 text: footer.sym(count),
855 ink: *ink,
856 bold: false,
857 joined: index > 0,
858 count_index: Some(index),
859 });
860 }
861 }
862 if let Some((clock, ink)) = footer.session_clock.filter(|_| shed < SHED_SESSION_CLOCK) {
863 items.push(PostureItem {
864 text: footer.sym(clock),
865 ink,
866 bold: false,
867 joined: false,
868 count_index: None,
869 });
870 }
871 // The cap warning is not a hint: it sheds after the counts and both
872 // clock halves, because a full context is the one thing that stops the
873 // next turn from starting at all.
874 let hint_rung = if footer.at_context_cap() {
875 SHED_CAP_WARNING
876 } else {
877 SHED_HINT
878 };
879 if shed < hint_rung
880 && let Some((text, ink)) = footer.effective_hint()
881 {
882 items.push(PostureItem {
883 text,
884 ink,
885 bold: false,
886 joined: false,
887 count_index: None,
888 });
889 }
890 items
891 }
892
893 /// The separator painted before an item: `, ` inside the counts group,
894 /// ` · ` between groups.
895 fn separator_before(item: &PostureItem) -> &'static str {
896 if item.joined {
897 COUNT_SEPARATOR
898 } else {
899 ITEM_SEPARATOR
900 }
901 }
902
903 fn left_run_width(items: &[PostureItem]) -> usize {
904 items.iter().map(|item| item.text.width()).sum::<usize>()
905 + items
906 .iter()
907 .skip(1)
908 .map(|item| separator_before(item).width())
909 .sum::<usize>()
910 }
911
912 /// Paint the posture bar (spec §5b: `Constraint::Length(1)`).
913 ///
914 /// Left: the mark, the permission chip, the mode, the counts, the hint —
915 /// shed from the right until the run fits beside the pinned right slot.
916 /// Right: the notice or remote-control state, clause-shed by the caller and
917 /// truncated here as the last resort; it never covers the permission chip.
918 /// Paint the posture bar. Returns the painted rect of each live count, by
919 /// its index into `footer.counts`, so the caller can make the counts the
920 /// bottom-of-screen affordance that opens the matching dock view.
921 pub fn render_tideline_footer(
922 area: Rect,
923 buf: &mut Buffer,
924 footer: &TidelineFooter<'_>,
925 ) -> Vec<(usize, Rect)> {
926 let mut count_rects = Vec::new();
927 if area.width < 8 || area.height < 1 {
928 return count_rects;
929 }
930 let area = area.inner(ratatui::layout::Margin::new(1, 0));
931 let theme = footer.theme;
932 let width = usize::from(area.width);
933
934 // The permission chip alone is the floor; the right slot takes what is
935 // left after it, and the rest of the left run sheds against the slot.
936 let floor = left_run_width(&posture_items(footer, MAX_SHED));
937 let right = footer.right.map(|(text, ink)| {
938 let budget = width.saturating_sub(floor + 1);
939 (
940 crate::tui::ui_text::truncate_line_to_width(&footer.sym(text), budget),
941 ink,
942 )
943 });
944 let right_width = right
945 .as_ref()
946 .map(|(text, _)| text.width() + 1)
947 .unwrap_or(0);
948 let left_budget = width.saturating_sub(right_width);
949 let items = (footer.first_shed_rung()..=MAX_SHED)
950 .map(|shed| posture_items(footer, shed))
951 .find(|items| left_run_width(items) <= left_budget)
952 .unwrap_or_else(|| posture_items(footer, MAX_SHED));
953
954 let mut x = usize::from(area.x);
955 let clip = |x: usize, text: &str| -> String {
956 crate::tui::ui_text::truncate_line_to_width(
957 text,
958 (usize::from(area.x) + left_budget).saturating_sub(x),
959 )
960 };
961 for (index, item) in items.iter().enumerate() {
962 if index > 0 {
963 // Projected like every other glyph on the row: an ascii-safe
964 // terminal that cannot draw `·` must not get one here either,
965 // least of all next to a clock reading whose own separator was
966 // projected.
967 let separator = footer.sym(separator_before(item));
968 tput(
969 buf,
970 x as u16,
971 area.y,
972 &clip(x, &separator),
973 tchrome(theme, ChromeInk::MetadataDim),
974 );
975 x += separator.width();
976 }
977 let mut style = tchrome(theme, item.ink);
978 if item.bold {
979 style = style.add_modifier(Modifier::BOLD);
980 }
981 let text = clip(x, &item.text);
982 // Keep the state legible while its taught keyboard hint recedes.
983 // Only known shortcut suffixes qualify; parenthetical scope/warnings
984 // retain their semantic ink.
985 let key_start = [footer.permission_key, footer.mode_key, Some("Ctrl+]")]
986 .into_iter()
987 .flatten()
988 .find_map(|key| {
989 let suffix = format!(" ({key})");
990 item.text
991 .ends_with(&suffix)
992 .then(|| item.text.len() - suffix.len())
993 });
994 if let Some(start) = key_start.filter(|start| *start < text.len()) {
995 let (label, key) = text.split_at(start);
996 tput(buf, x as u16, area.y, label, style);
997 tput(
998 buf,
999 (x + label.width()) as u16,
1000 area.y,
1001 key,
1002 tchrome(theme, ChromeInk::MetadataHint),
1003 );
1004 } else {
1005 tput(buf, x as u16, area.y, &text, style);
1006 }
1007 if let Some(count_index) = item.count_index
1008 && !text.is_empty()
1009 {
1010 count_rects.push((
1011 count_index,
1012 Rect::new(x as u16, area.y, text.width() as u16, 1),
1013 ));
1014 }
1015 x += item.text.width();
1016 }
1017
1018 if let Some((text, ink)) = right
1019 && !text.is_empty()
1020 {
1021 let sx = (usize::from(area.x) + width).saturating_sub(text.width());
1022 tput(buf, sx as u16, area.y, &text, tchrome(theme, ink));
1023 }
1024 count_rects
1025 }
1026
1027 /// Owned posture facts, built from real `App` state at render time and lent
1028 /// to [`TidelineFooter`] for painting.
1029 pub(crate) struct TidelineFooterFacts {
1030 pub permission_chip: (String, codewhale_palette::ChromeInk),
1031 pub permission_key: Option<&'static str>,
1032 pub mode_chip: Option<(String, codewhale_palette::ChromeInk)>,
1033 pub mode_key: Option<&'static str>,
1034 pub turn_clock: ClockReading,
1035 pub counts: Vec<(String, ChromeInk)>,
1036 pub session_clock: ClockReading,
1037 /// The action each entry of `counts` runs when clicked — same
1038 /// length, same order.
1039 pub count_actions: Vec<crate::tui::tideline::InteractionAction>,
1040 pub hint: Option<(String, codewhale_palette::ChromeInk)>,
1041 pub context_percent: u8,
1042 pub right: Option<(String, codewhale_palette::ChromeInk)>,
1043 }
1044
1045 impl TidelineFooterFacts {
1046 /// Borrow the facts as the deterministic widget's input.
1047 pub(crate) fn widget<'a>(
1048 &'a self,
1049 theme: &'a codewhale_palette::UiTheme,
1050 ascii_safe: bool,
1051 ) -> TidelineFooter<'a> {
1052 let borrow = |chip: &'a Option<(String, ChromeInk)>| {
1053 chip.as_ref().map(|(text, ink)| (text.as_str(), *ink))
1054 };
1055 TidelineFooter::new(
1056 theme,
1057 (self.permission_chip.0.as_str(), self.permission_chip.1),
1058 )
1059 .permission_key(self.permission_key)
1060 .mode_chip(borrow(&self.mode_chip))
1061 .mode_key(self.mode_key)
1062 .turn_clock(borrow(&self.turn_clock))
1063 .counts(&self.counts)
1064 .session_clock(borrow(&self.session_clock))
1065 .hint(borrow(&self.hint))
1066 .context_percent(self.context_percent)
1067 .right(borrow(&self.right))
1068 .ascii_safe(ascii_safe)
1069 }
1070 }
1071
1072 /// The session has to have worked this long before the bar states a total.
1073 /// A fresh launch that says `worked 4s` is furniture, not information; the
1074 /// classic footer's `worked` chip used the same floor (#448).
1075 const CLOCK_SESSION_FLOOR_SECS: u64 = 60;
1076
1077 /// One half of the working clock: its text and the ink that says whether the
1078 /// clock is running. `None` when that half has nothing true to say.
1079 pub(crate) type ClockReading = Option<(String, ChromeInk)>;
1080
1081 /// The working clock (#5914), as its two halves — `(turn, session)`.
1082 ///
1083 /// * the **turn** reading is `{phase_label} {elapsed}` — the phase word is
1084 /// the transcript's own, so `working 1m 15s` and `waiting on you 1m 15s`
1085 /// and `sub-agents underway 1m 15s` all say what the clock is counting.
1086 /// A duration alone cannot distinguish a session producing tokens from one
1087 /// parked on a tool, a sub-agent, or an unanswered prompt. `None` between
1088 /// turns: there is no turn to time.
1089 /// * the **session** reading is the classic `worked {elapsed}` chip (#448):
1090 /// `App::cumulative_turn_duration` (the sum of finished turns) plus the
1091 /// live turn, so it ticks continuously and never jumps at `TurnComplete`.
1092 /// It is model work, not wall clock since launch — an idle TUI does not
1093 /// claim to have been working. Quiet ink while no turn is running, because
1094 /// the clock is stopped. Suppressed while it would repeat the turn
1095 /// reading — on a session's first turn the two are the same duration
1096 /// (#6041); it returns as soon as a finished turn makes the totals
1097 /// different.
1098 pub(crate) fn working_clock(
1099 app: &App,
1100 phase: ShellPhase,
1101 phase_label: &str,
1102 ) -> (ClockReading, ClockReading) {
1103 let turn = app.turn_started_at.map(|started| started.elapsed());
1104 let ink = match phase {
1105 ShellPhase::Waiting | ShellPhase::Approval => ChromeInk::Waiting,
1106 ShellPhase::Failed => ChromeInk::Attention,
1107 _ => ChromeInk::Active,
1108 };
1109 let turn_clock = turn.map(|turn| {
1110 (
1111 format!(
1112 "{phase_label} {}",
1113 crate::elapsed::format_elapsed_secs(turn.as_secs())
1114 ),
1115 ink,
1116 )
1117 });
1118 let worked = app
1119 .cumulative_turn_duration
1120 .saturating_add(turn.unwrap_or_default());
1121 // #6041: on a session's first turn there is no finished-turn total, so
1122 // the session reading would print the same duration the turn reading
1123 // already carries. The turn half names what is happening; the worked
1124 // chip earns its place only once a finished turn makes it a different
1125 // number.
1126 let repeats_turn = turn.is_some_and(|turn| turn.as_secs() == worked.as_secs());
1127 let session_clock =
1128 (worked.as_secs() >= CLOCK_SESSION_FLOOR_SECS && !repeats_turn).then(|| {
1129 (
1130 tr(app.ui_locale, MessageId::FooterWorkedChip).replace(
1131 "{duration}",
1132 &crate::elapsed::format_elapsed_secs(worked.as_secs()),
1133 ),
1134 if turn.is_some() {
1135 ink
1136 } else {
1137 ChromeInk::MetadataValue
1138 },
1139 )
1140 });
1141 (turn_clock, session_clock)
1142 }
1143
1144 /// Context window percentage — the snapshot the metrics line's reading
1145 /// paints, and the posture bar's ≥80% cap-warning trigger.
1146 pub(crate) fn context_percent_from_app(app: &App) -> u8 {
1147 crate::tui::ui::context_usage_snapshot(app)
1148 .map(|(_, _, percent)| percent.round().clamp(0.0, 100.0) as u8)
1149 .unwrap_or(0)
1150 }
1151
1152 /// The live counts: running sub-agents, live shells, background tasks, and
1153 /// scheduled automation. Each count is zero-suppressed — the bar never
1154 /// grows furniture for work that is not happening.
1155 fn live_counts(
1156 app: &App,
1157 tier: ShellTier,
1158 ) -> (
1159 Vec<(String, ChromeInk)>,
1160 Vec<crate::tui::tideline::InteractionAction>,
1161 ) {
1162 use crate::tui::background_indicator::{PendingItemKind, pending_work_from_app};
1163 use crate::tui::tideline::InteractionAction;
1164 use crate::tui::work_surface::RailPanel;
1165 let mut counts = Vec::new();
1166 let mut panels = Vec::new();
1167 let agents = crate::tui::subagent_routing::running_agent_count(app);
1168 match agents {
1169 0 => {}
1170 1 => {
1171 counts.push((
1172 tr(app.ui_locale, MessageId::FooterAgentSingular).into_owned(),
1173 ChromeInk::Active,
1174 ));
1175 panels.push(InteractionAction::ShowDockPanel(RailPanel::Agents));
1176 }
1177 n => {
1178 counts.push((
1179 tr(app.ui_locale, MessageId::FooterAgentsPlural).replace("{count}", &n.to_string()),
1180 ChromeInk::Active,
1181 ));
1182 panels.push(InteractionAction::ShowDockPanel(RailPanel::Agents));
1183 }
1184 }
1185 let shells = app
1186 .task_panel
1187 .iter()
1188 .filter(|entry| crate::tui::background_indicator::is_live_shell_entry(entry))
1189 .count();
1190 if shells > 0 {
1191 counts.push((
1192 format!("{shells} {}", PendingItemKind::Shell.plural_noun(shells)),
1193 ChromeInk::Active,
1194 ));
1195 panels.push(InteractionAction::ShowDockPanel(RailPanel::Background));
1196 }
1197 let tasks = pending_work_from_app(app).count(PendingItemKind::Task);
1198 if tasks > 0 {
1199 counts.push((
1200 format!("{tasks} {}", PendingItemKind::Task.plural_noun(tasks)),
1201 ChromeInk::Active,
1202 ));
1203 panels.push(InteractionAction::ShowDockPanel(RailPanel::Background));
1204 }
1205 // Scheduled automation: the `AutomationPanelState` projection stays the
1206 // single owner; Compact keeps the abbreviated count (chrome sheds
1207 // before content) and the ink says whether a run failed unacknowledged.
1208 let automation = if tier == ShellTier::Compact {
1209 app.automation_panel.activity_slot_compact()
1210 } else {
1211 app.automation_panel.activity_slot(app.ui_locale)
1212 };
1213 if let Some(automation) = automation {
1214 counts.push((automation, app.automation_panel.activity_ink()));
1215 panels.push(InteractionAction::OpenAutomations);
1216 }
1217 // With nothing live there is still one bottom affordance that opens the
1218 // dock (founder, 2026-09-03: the bar opens when used, or when you click
1219 // something at the bottom to ask for it). The word is the dock's own
1220 // TODO view title; it disappears while the dock is up.
1221 //
1222 // It reads at `MetadataValue`, not `MetadataDim`: `TEXT_DIM` is aliased to
1223 // `TEXT_HINT` in every theme but Solarized, so a dim affordance paints the
1224 // exact grey as the separators around it and the one clickable word in an
1225 // idle footer disappears into punctuation. Live counts already carry
1226 // `Active`; this is the idle case earning the same "you can click this".
1227 if counts.is_empty() && app.work_surface.last_area.is_none() {
1228 // The word alone did not say it was an affordance, let alone which
1229 // key opened it — founder live-test: "what do we press at the bottom
1230 // to get the workbar to show up?". It carries its chord until the
1231 // binding has been used, exactly like the permission and mode chips.
1232 let label = "Work bar".to_string();
1233 let chord = crate::tui::shell_key_routing::binding(
1234 crate::tui::shell_key_routing::ShellBindingId::ViewCycle,
1235 )
1236 .footer_chord;
1237 let label = if crate::tui::footer_hints::retired(
1238 &app.footer_hint_uses,
1239 crate::tui::footer_hints::DOCK_OPEN,
1240 ) {
1241 label
1242 } else {
1243 format!("{label} ({chord})")
1244 };
1245 counts.push((label, ChromeInk::Info));
1246 panels.push(InteractionAction::ShowDockPanel(RailPanel::Tasks));
1247 }
1248 (counts, panels)
1249 }
1250
1251 /// Build the posture bar's facts from live `App` state. `width` is the
1252 /// row's width — notices clause-shed against it, never dangle.
1253 pub(crate) fn tideline_footer_from_app(app: &mut App, width: u16) -> TidelineFooterFacts {
1254 use crate::tui::shell_key_routing::{ShellBindingId, binding};
1255 let activity = LiveActivity::from_app(app);
1256 let phase = ShellPhase::from_app_with_activity(app, activity);
1257 let (_, phase_label) = phase_marker_with_activity(app, phase, activity);
1258 let tier = ShellTier::for_chrome_width(width);
1259 let focus = app.focus();
1260
1261 let (mode_chip, permission_chip) = crate::tui::underwater::posture_chips(app);
1262 let permission_chip = permission_chip
1263 .map(|(text, ink)| (text.into_owned(), ink))
1264 .unwrap_or_else(|| (String::new(), ChromeInk::PermissionAsk));
1265 // The mode chip is the one posture fact `/statusline` composes (#5950):
1266 // its `StatusItem::Mode` toggle used to be inert. The permission chip,
1267 // the working clocks (#5914) and the live counts are the bar's own
1268 // posture statement and stay unconditional.
1269 let mode_chip = mode_chip
1270 .filter(|_| app.status_items.contains(&crate::config::StatusItem::Mode))
1271 .map(|(text, ink)| (text.into_owned(), ink));
1272 // Cycle keys come from the binding table and only when that binding is
1273 // live for the current focus.
1274 let live_chord = |id: ShellBindingId| -> Option<&'static str> {
1275 let binding = binding(id);
1276 binding.focus.admits(focus).then_some(binding.footer_chord)
1277 };
1278
1279 // The one hint that applies now: the double-tap send-now window while a
1280 // turn is running, else the interrupt affordance, else the arrow keys
1281 // the empty composer lends to the agent roster. Each hint retires once
1282 // its binding has been used enough times (`footer_hints::retired`): a
1283 // taught binding renders the bare state, never more chrome.
1284 let hint = if app.double_tap_window_open() {
1285 Some((
1286 tr(app.ui_locale, MessageId::PostureHintEnterAgain)
1287 .replace("{enter}", "Enter")
1288 .replace("{steer}", "Ctrl+Enter"),
1289 ChromeInk::MetadataHint,
1290 crate::tui::footer_hints::ENTER_AGAIN,
1291 ))
1292 } else if matches!(phase, ShellPhase::Working | ShellPhase::Verifying) {
1293 Some((
1294 tr(app.ui_locale, MessageId::FooterHintEscInterrupt).into_owned(),
1295 ChromeInk::MetadataHint,
1296 crate::tui::footer_hints::ESC_INTERRUPT,
1297 ))
1298 } else if crate::tui::agent_focus::shell_shortcuts_available(app, false) {
1299 Some((
1300 crate::tui::agent_focus::footer_agent_hints(app),
1301 ChromeInk::MetadataHint,
1302 crate::tui::footer_hints::AGENT_ARROWS,
1303 ))
1304 } else {
1305 None
1306 };
1307 let hint = hint
1308 .filter(|(_, _, key)| !crate::tui::footer_hints::retired(&app.footer_hint_uses, key))
1309 .map(|(text, ink, _)| (text, ink));
1310
1311 // The right slot: the live status toast if one is owed, else the compact
1312 // MCP or plugin boot chip, else the remote-control state when it is on.
1313 // Clause-shed against half the row — the posture facts own the other
1314 // half.
1315 let notice_budget = (usize::from(width) / 2).max(8);
1316 let right = selected_notice(app.active_status_toast(phase), &phase_label)
1317 .map(|(text, ink, _urgent)| {
1318 (
1319 text,
1320 if ink == ChromeInk::Info {
1321 ChromeInk::Metadata
1322 } else {
1323 ink
1324 },
1325 )
1326 })
1327 .or_else(|| {
1328 // The launch screen carries the full MCP block — every state, with
1329 // the failing and unauthorized servers named. Repeating a squeezed
1330 // one-server chip down here would give the same fact two homes and
1331 // show strictly less of it. Suppress the chip, not the surface:
1332 // `SessionBootSurface::from_app` stays untouched so every other
1333 // consumer of boot state, including the launch block itself, is
1334 // unaffected.
1335 if app.launch.visible {
1336 return None;
1337 }
1338 let boot = crate::tui::session_boot::SessionBootSurface::from_app(app);
1339 boot.activity_notice(app.ui_locale, notice_budget)
1340 .map(|chip| (chip.text, boot_activity_ink(chip.level)))
1341 })
1342 .and_then(|(text, ink)| fit_notice(&text, notice_budget).map(|fitted| (fitted, ink)))
1343 .or_else(|| {
1344 app.remote_control
1345 .status_word()
1346 .map(|word| (format!("/rc {word}"), ChromeInk::Info))
1347 });
1348
1349 let (turn_clock, session_clock) = working_clock(app, phase, &phase_label);
1350 let (counts, count_actions) = live_counts(app, tier);
1351 TidelineFooterFacts {
1352 permission_chip,
1353 permission_key: live_chord(ShellBindingId::PermissionCycle).filter(|_| {
1354 !crate::tui::footer_hints::retired(
1355 &app.footer_hint_uses,
1356 crate::tui::footer_hints::PERMISSION_CYCLE,
1357 )
1358 }),
1359 mode_chip,
1360 mode_key: live_chord(ShellBindingId::ModeCycle).filter(|_| {
1361 !crate::tui::footer_hints::retired(
1362 &app.footer_hint_uses,
1363 crate::tui::footer_hints::MODE_CYCLE,
1364 )
1365 }),
1366 turn_clock,
1367 counts,
1368 session_clock,
1369 count_actions,
1370 hint,
1371 context_percent: context_percent_from_app(app),
1372 right,
1373 }
1374 }
1375
1376 #[cfg(test)]
1377 mod tideline_tests;
1378
1379 #[cfg(test)]
1380 mod neutrality_tests {
1381 #[test]
1382 fn session_metrics_strip_is_on_by_default() {
1383 assert!(
1384 crate::config::StatusItem::default_footer().contains(&crate::config::StatusItem::Ttft)
1385 && crate::config::StatusItem::default_footer()
1386 .contains(&crate::config::StatusItem::OutputRate)
1387 );
1388 assert_eq!(
1389 crate::config::StatusItem::from_key("session_metrics"),
1390 Some(crate::config::StatusItem::SessionMetrics)
1391 );
1392 }
1393 }
1394
1394 lines RUST