返回 CodeWhale
frame.rs
根目录 / crates / tui / src / tui / ui / frame.rs
1 //! Frame composition: the draw entry point, the builders that assemble what a
2 //! frame needs, and streaming-text accumulation into history cells.
3 //!
4 //! Moved verbatim out of `ui.rs`.
5
6 use super::*;
7 use crate::tui::infoline::{InfoLine, InfoSegment, InfoSegmentId, infoline_hitboxes};
8 use codewhale_models::Role;
9
10 /// Context window percentage for the metrics line's reading — the same
11 /// snapshot the posture bar's ≥80% microcopy reads, so the two can never
12 /// disagree.
13 pub(crate) fn info_context_percent(app: &App) -> u8 {
14 crate::tui::phase_strip::context_percent_from_app(app)
15 }
16
17 /// Format the session's cumulative usage chip for the metrics line. `/cost`
18 /// has its own detailed receipt and coverage report; it does not call this
19 /// formatter. Chips without a cost display produce an empty string.
20 ///
21 /// Incomplete cost includes its receipt's reason, including an unclassified
22 /// billing route. A provider switch cannot erase earlier missing coverage.
23 pub(crate) fn session_cost_label(app: &App) -> String {
24 use crate::route_billing::UsageChip;
25 let usage_chip = app.cumulative_usage_chip();
26 match &usage_chip {
27 UsageChip::Money(amount) => Some(amount.clone()),
28 UsageChip::PricedSubtotal { .. } | UsageChip::Unknown(_) => {
29 crate::route_billing::format_usage_chip(&usage_chip, app.ui_locale)
30 }
31 _ => None,
32 }
33 .unwrap_or_default()
34 }
35
36 /// The clock-dependent billing tier of the active route, when the route has
37 /// one: DeepSeek's V4 Pro/Flash and Flash halve their rates off-peak. `None`
38 /// for flat-priced routes, for other vendors, and while auto routing has not
39 /// pinned a concrete model.
40 pub(crate) fn billing_tier_label(app: &App, now: chrono::DateTime<chrono::Utc>) -> Option<String> {
41 use crate::config::ApiProvider;
42 use codewhale_localization::{MessageId, tr};
43 if app.auto_model
44 || !matches!(
45 app.api_provider.catalog_identity(),
46 ApiProvider::Deepseek | ApiProvider::DeepseekCN
47 )
48 {
49 return None;
50 }
51 let peak = crate::pricing::deepseek_time_tier(&app.model, now)?;
52 let id = if peak {
53 MessageId::InfoLinePeak
54 } else {
55 MessageId::InfoLineOffPeak
56 };
57 Some(tr(app.ui_locale, id).into_owned())
58 }
59
60 /// Output tokens for the metrics line: the live stream's running estimate,
61 /// else the last turn's provider receipt. Request throughput is independently
62 /// sourced from SessionMetrics, so a long tool call cannot lower that rate.
63 fn output_tokens(app: &App) -> Option<u64> {
64 if app.is_loading && app.streaming_output_token_estimate > 0 {
65 return Some(app.streaming_output_token_estimate);
66 }
67 app.session
68 .last_completion_tokens
69 .filter(|tokens| *tokens > 0)
70 .map(u64::from)
71 }
72
73 /// Build the metrics line's segments from live `App` state. Shedding is the
74 /// widget's job; this only states the facts, in display order: model,
75 /// context, cost, balance, time to first token, output rate, output tokens.
76 ///
77 /// Repository and branch left this row (2026-09-02): the launch header and
78 /// the git bottom view own them. Fleet, whale and automation counts left too —
79 /// the posture bar's live counts own activity.
80 ///
81 /// Composition is the user's (#5950): every segment here is gated on the
82 /// matching [`StatusItem`] in `app.status_items`, which is what `/statusline`
83 /// edits and `tui.status_items` persists. Between 0.9.12 and this change the
84 /// row ignored that list entirely and the picker's toggles did nothing.
85 pub(crate) fn info_segments(app: &App, width: u16) -> Vec<InfoSegment> {
86 use crate::config::StatusItem;
87 use codewhale_localization::MessageId;
88 use codewhale_palette::ChromeInk;
89 let mut segments = Vec::new();
90 let tier = crate::tui::underwater::ShellTier::for_chrome_width(width);
91 let shows = |item: StatusItem| app.status_items.contains(&item);
92
93 // Where this session writes (#6112): the workspace leaf and the branch
94 // the next commit lands on. Both read cached state only — the branch
95 // comes from `app.workspace_context`, refreshed off the render path on
96 // the workspace-context TTL, so neither chip costs IO per frame. They
97 // lead the row: identity of place before identity of route. The branch
98 // chip degrades to absent outside a repository rather than printing a
99 // permanent dash.
100 if shows(StatusItem::Workspace) {
101 let name = crate::tui::workspace_context::status_workspace_name(
102 &app.workspace,
103 app.workspace_is_linked_worktree,
104 );
105 segments.push(InfoSegment::new(
106 InfoSegmentId::Workspace,
107 "",
108 crate::tui::workspace_context::truncate_left(
109 &name,
110 crate::tui::workspace_context::STATUS_CHIP_MAX_WIDTH,
111 ),
112 ChromeInk::MetadataValue,
113 ));
114 }
115 if shows(StatusItem::GitBranch)
116 && let Some(branch) = app
117 .workspace_context
118 .as_deref()
119 .and_then(crate::tui::workspace_context::branch_from_context)
120 {
121 segments.push(InfoSegment::new(
122 InfoSegmentId::GitBranch,
123 "",
124 crate::tui::workspace_context::truncate_left(
125 &if app.workspace_is_linked_worktree {
126 format!("{branch} (wt)")
127 } else {
128 branch.to_string()
129 },
130 crate::tui::workspace_context::STATUS_CHIP_MAX_WIDTH,
131 ),
132 ChromeInk::MetadataValue,
133 ));
134 }
135
136 // Route identity — the old identity band's fact, same shed discipline:
137 // provider first, then effort, whole names or none. When no model is
138 // configured the segment says so and waits.
139 // Off the row when the user says so: `/model`, the picker and the launch
140 // header all still name the route.
141 if shows(StatusItem::Model) {
142 let (_, model) = app.effective_route_identity_display();
143 if model.is_empty() {
144 segments.push(InfoSegment::new(
145 InfoSegmentId::Model,
146 app.tr(MessageId::StartupDefaultSubjectModel).as_ref(),
147 app.tr(MessageId::InfoLineNotConnected).as_ref(),
148 ChromeInk::Waiting,
149 ));
150 } else {
151 // The context reading and the metrics claim the rest of the row;
152 // the route sheds its own qualifiers first.
153 let budget = crate::tui::phase_strip::info_route_budget(width);
154 let fields = crate::tui::phase_strip::route_identity_fields(app, tier, budget)
155 .unwrap_or_else(|| {
156 vec![crate::tui::phase_strip::RouteIdentityField {
157 kind: crate::tui::phase_strip::RouteFieldKind::Model,
158 text: model,
159 }]
160 });
161 segments.push(InfoSegment::new(
162 InfoSegmentId::Model,
163 "",
164 fields
165 .iter()
166 .map(|field| field.text.as_str())
167 .collect::<Vec<_>>()
168 .join(ROUTE_FIELD_JOIN),
169 ChromeInk::MetadataValue,
170 ));
171 }
172 }
173
174 // The context reading: painted here and nowhere else, at every
175 // fullness. The 0.9.12 row went silent below 50% and left most of a
176 // session with no context signal at all (#5950); watching the number
177 // climb from 4% is the whole point of the reading. At the 80% cap the
178 // whole reading turns to the error token — it is the one fact on this
179 // row that becomes a problem rather than a status.
180 let pct = info_context_percent(app);
181 if shows(StatusItem::ContextPercent) {
182 segments.push(InfoSegment::new(
183 InfoSegmentId::Context,
184 app.tr(MessageId::InfoLineContext).as_ref(),
185 format!("{pct}%"),
186 // The posture bar one row above calls this exact threshold
187 // `ChromeInk::Attention` (`phase_strip::at_context_cap`, also >= 80).
188 // One condition, one family: a full context is consequential, not a
189 // failure — the next turn still runs and `/compact` is the remedy.
190 if pct >= 80 {
191 ChromeInk::Attention
192 } else {
193 ChromeInk::Info
194 },
195 ));
196 }
197
198 // The active goal's live reading: elapsed time plus the model's latest
199 // reported progress with its bar. Painted only while a goal is actually
200 // active — the percent is the model's own estimate, and the row never
201 // invents one for a goal that has not reported.
202 if app.goal.status == crate::tools::goal::GoalStatus::Active
203 && app.goal.objective.is_some()
204 && let Some(started) = app.goal.started_at
205 {
206 let secs = started.elapsed().as_secs();
207 let elapsed = if secs < 60 {
208 format!("{secs}s")
209 } else {
210 format!("{}m", secs / 60)
211 };
212 let value = match app.goal.progress.as_ref() {
213 Some(progress) => format!(
214 "({elapsed}) {}% {}",
215 progress.percent,
216 crate::tools::goal::goal_progress_bar(progress.percent)
217 ),
218 None => format!("({elapsed})"),
219 };
220 segments.push(InfoSegment::new(
221 InfoSegmentId::Goal,
222 app.tr(MessageId::GoalProgressLabel).as_ref(),
223 value,
224 ChromeInk::Info,
225 ));
226 }
227
228 let cost = session_cost_label(app);
229 if shows(StatusItem::Cost) && !cost.is_empty() {
230 segments.push(InfoSegment::new(
231 InfoSegmentId::Cost,
232 "",
233 cost,
234 ChromeInk::MetadataValue,
235 ));
236 }
237
238 // DeepSeek bills by the clock: the same flag that halves the rates
239 // off-peak is painted beside the cost, so the operator can see which tier
240 // the next turn buys without opening /cost. Gated on the cost item, whose
241 // owner asked for price readings by name.
242 if shows(StatusItem::Cost)
243 && let Some(tier) = billing_tier_label(app, chrono::Utc::now())
244 {
245 segments.push(InfoSegment::new(
246 InfoSegmentId::BillingTier,
247 "",
248 tier,
249 ChromeInk::MetadataValue,
250 ));
251 }
252
253 // The prepaid-credit reading: opt-in, and the same status item that
254 // authorises the background fetch (`should_fetch_provider_balance`), so
255 // the row can only show a number this session actually asked for.
256 if shows(StatusItem::Balance)
257 && let Some(balance) = app.balance_cell.lock().ok().and_then(|guard| {
258 guard
259 .as_ref()
260 .and_then(crate::pricing::BalanceInfo::chip_label)
261 })
262 {
263 segments.push(InfoSegment::new(
264 InfoSegmentId::Balance,
265 app.tr(MessageId::FooterBalancePrefix).as_ref(),
266 balance,
267 ChromeInk::MetadataValue,
268 ));
269 }
270
271 // The DeepSeek-harness session metrics, from the same accumulators
272 // `/cost` prints: nothing here is estimated except the live stream's
273 // running token count, which the provider's receipt replaces.
274 if (shows(StatusItem::SessionMetrics) || shows(StatusItem::Ttft))
275 && let Some(ttft) = app.session_metrics.ttft_average()
276 {
277 segments.push(InfoSegment::new(
278 InfoSegmentId::Ttft,
279 app.tr(MessageId::InfoLineTtft).as_ref(),
280 crate::tui::session_metrics::format_duration(ttft),
281 ChromeInk::MetadataValue,
282 ));
283 }
284 if (shows(StatusItem::SessionMetrics) || shows(StatusItem::OutputRate))
285 && let Some(rate) = app.session_metrics.tokens_per_second()
286 {
287 segments.push(InfoSegment::new(
288 InfoSegmentId::Rate,
289 "",
290 format!(
291 "{} {}",
292 crate::tui::session_metrics::format_rate(rate),
293 app.tr(MessageId::SessionMetricsTokensPerSecond)
294 ),
295 ChromeInk::MetadataValue,
296 ));
297 }
298 if let Some(tokens) = output_tokens(app) {
299 let hit = u64::from(app.session.displayed_total_cache_hit_tokens());
300 let miss = u64::from(app.session.displayed_total_cache_miss_tokens());
301 let cache_total = hit + miss;
302 if shows(StatusItem::Cache) && cache_total > 0 {
303 let cache_pct = (hit * 100 + cache_total / 2)
304 .checked_div(cache_total)
305 .and_then(|pct| u8::try_from(pct).ok())
306 .unwrap_or(100);
307 segments.push(InfoSegment::new(
308 InfoSegmentId::Cache,
309 "cache",
310 format!("{cache_pct}%"),
311 ChromeInk::MetadataValue,
312 ));
313 }
314 if shows(StatusItem::Tokens) {
315 segments.push(InfoSegment::new(
316 InfoSegmentId::OutputTokens,
317 "↓",
318 crate::tui::session_metrics::format_tokens(tokens),
319 ChromeInk::MetadataValue,
320 ));
321 }
322 } else {
323 let hit = u64::from(app.session.displayed_total_cache_hit_tokens());
324 let miss = u64::from(app.session.displayed_total_cache_miss_tokens());
325 let cache_total = hit + miss;
326 if shows(StatusItem::Cache) && cache_total > 0 {
327 let cache_pct = (hit * 100 + cache_total / 2)
328 .checked_div(cache_total)
329 .and_then(|pct| u8::try_from(pct).ok())
330 .unwrap_or(100);
331 segments.push(InfoSegment::new(
332 InfoSegmentId::Cache,
333 "cache",
334 format!("{cache_pct}%"),
335 ChromeInk::MetadataValue,
336 ));
337 }
338 }
339
340 segments
341 }
342
343 /// The info line's controls that actually painted in this frame.
344 ///
345 /// The route target intentionally contains no copied route metadata. The
346 /// provider picker retains catalog, readiness, credential, and apply
347 /// authority; chrome only exposes its entry point.
348 #[derive(Debug, Clone, Copy, Default)]
349 struct InfoLineInteractionHitboxes {
350 context: Option<Rect>,
351 /// The provider name inside the route segment, when the row is wide
352 /// enough to render one.
353 route: Option<Rect>,
354 /// The model name and its effort tier — one span, because they are
355 /// always adjacent and `/model` owns both.
356 model: Option<Rect>,
357 }
358
359 /// Separator between rendered route fields. Three columns wide, matching
360 /// `phase_strip::ITEM_SEPARATOR_WIDTH`, which is what the shed budget counts.
361 const ROUTE_FIELD_JOIN: &str = " · ";
362
363 /// Split the route segment's rect back into its fields.
364 ///
365 /// The segment renders as `provider · model · effort`; a click on the
366 /// provider belongs to `/provider` and a click on the model or its effort
367 /// tier belongs to `/model`. Widths come from the same field texts the
368 /// segment was built from, so the split cannot disagree with what is on
369 /// screen. Returns `(provider, model-and-effort)`.
370 fn split_route_hitbox(
371 fields: &[crate::tui::phase_strip::RouteIdentityField],
372 area: Rect,
373 ) -> (Option<Rect>, Option<Rect>) {
374 use crate::tui::phase_strip::RouteFieldKind;
375 use unicode_width::UnicodeWidthStr as _;
376
377 let join = ROUTE_FIELD_JOIN.width();
378 let right = usize::from(area.right());
379 let mut x = usize::from(area.x);
380 let mut provider = None;
381 let mut model: Option<Rect> = None;
382 for (index, field) in fields.iter().enumerate() {
383 if index > 0 {
384 x += join;
385 }
386 let end = (x + field.text.width()).min(right);
387 if x >= end {
388 break;
389 }
390 let rect = Rect {
391 x: x as u16,
392 y: area.y,
393 width: (end - x) as u16,
394 height: 1,
395 };
396 match field.kind {
397 RouteFieldKind::Provider => provider = Some(rect),
398 // Model and effort are adjacent and share a destination, so the
399 // span grows rather than replacing — a two-target registration
400 // for one idea just gives the pointer a seam to fall into.
401 RouteFieldKind::Model | RouteFieldKind::Effort => {
402 model = Some(match model {
403 Some(prev) => Rect {
404 x: prev.x,
405 y: prev.y,
406 width: rect.right().saturating_sub(prev.x),
407 height: 1,
408 },
409 None => rect,
410 });
411 }
412 }
413 x = end;
414 }
415 (provider, model)
416 }
417
418 /// Render the info line into its one row and record its segment
419 /// hitboxes (spec §5b `Constraint::Length(1)`). The ONE header on every
420 /// screen: the session shell and the launch screen both call this, so the
421 /// brand lockup, contextual segments, and the pinned meter + clock never
422 /// change identity between pre- and post-session states. Segment rects are
423 /// recorded for hover (this frame's highlight resolves against the previous
424 /// frame's rects, the standard one-frame-lag registry pattern) and for typed
425 /// click routing.
426 fn render_info_row(
427 f: &mut Frame,
428 app: &mut App,
429 area: Rect,
430 identity_only: bool,
431 ) -> InfoLineInteractionHitboxes {
432 if area.height == 0 {
433 app.viewport.last_infoline_hitboxes.clear();
434 return InfoLineInteractionHitboxes::default();
435 }
436 // The two bottom rows share the composer's one-cell inset. Paint the
437 // full band before insetting so hover/click geometry uses the same area.
438 Block::default()
439 .style(Style::default().bg(app.ui_theme.header_bg))
440 .render(area, f.buffer_mut());
441 let area = area.inner(ratatui::layout::Margin::new(u16::from(area.width >= 8), 0));
442 let mut segments = info_segments(app, area.width);
443 if identity_only {
444 segments.retain(|segment| {
445 matches!(
446 segment.id,
447 InfoSegmentId::Model | InfoSegmentId::Workspace | InfoSegmentId::GitBranch
448 )
449 });
450 }
451 let hovered = app.last_mouse_pos.and_then(|(mx, my)| {
452 app.viewport
453 .last_infoline_hitboxes
454 .iter()
455 .find(|hb| {
456 matches!(hb.id, InfoSegmentId::Model | InfoSegmentId::Context)
457 && hb.area.x <= mx
458 && mx < hb.area.right()
459 && hb.area.y == my
460 })
461 .map(|hb| hb.id)
462 });
463 // The metrics line no longer pins `/help` forever (founder, 2026-09-08).
464 // The route still appears until its binding has been used, then retires
465 // with the other footer hints so the row stays quiet once help is learned.
466 let help_hint = if crate::tui::footer_hints::retired(
467 &app.footer_hint_uses,
468 crate::tui::footer_hints::HELP_ROUTE,
469 ) {
470 String::new()
471 } else {
472 crate::tui::shell_key_routing::info_help_hint(app.ui_locale)
473 };
474 let info = InfoLine::new(&app.ui_theme, &help_hint, &segments)
475 .ascii_safe(crate::tui::color_compat::ascii_safe_enabled())
476 .hovered(hovered)
477 .compact(app.metrics_line == crate::config::ChromeRowPreset::Compact);
478 let hitboxes = infoline_hitboxes(&info, area);
479 let route_area = hitboxes
480 .iter()
481 .find(|hitbox| hitbox.id == InfoSegmentId::Model)
482 .map(|hitbox| hitbox.area);
483 // Same pure call `info_segments` made, with the same budget owner, so the
484 // split lines up with the text that was just measured.
485 let route_fields = crate::tui::phase_strip::route_identity_fields(
486 app,
487 crate::tui::underwater::ShellTier::for_chrome_width(area.width),
488 crate::tui::phase_strip::info_route_budget(area.width),
489 );
490 let (provider_area, model_area) = match (route_area, route_fields.as_deref()) {
491 (Some(area), Some(fields)) => split_route_hitbox(fields, area),
492 // No configured model: the segment says so and is not a route control.
493 // No drawn segment: nothing to point at either way.
494 (area, None) => (None, area),
495 (None, Some(_)) => (None, None),
496 };
497 let interaction_hitboxes = InfoLineInteractionHitboxes {
498 context: crate::tui::infoline::context_meter_hitbox(&info, area),
499 route: provider_area,
500 model: model_area,
501 };
502 // Keep the row's quiet background under the widget itself.
503 let buf = f.buffer_mut();
504 Block::default()
505 .style(Style::default().bg(app.ui_theme.header_bg))
506 .render(area, buf);
507 ratatui::widgets::Widget::render(info, area, buf);
508 app.viewport.last_infoline_hitboxes = hitboxes;
509 interaction_hitboxes
510 }
511
512 /// Register the chrome that already answers a click, so it also answers the
513 /// pointer.
514 ///
515 /// "What responds to the pointer going over it right now across the entire
516 /// app" — the honest answer had been: links, truncated text, and the info
517 /// line. The jump-to-latest button, the plugin call-to-action, and the
518 /// workflow panel all handled clicks in `mouse_ui` and lit up for nothing,
519 /// which teaches a person that pointing at things does not work here.
520 ///
521 /// This runs after the frame body has recorded its rects and before hover is
522 /// resolved, so it stays one list rather than a `register_rect` scattered
523 /// through every widget that happens to remember.
524 fn register_clickable_chrome_for_hover(app: &App) {
525 use codewhale_localization::MessageId;
526 let targets: [(Option<Rect>, MessageId); 4] = [
527 (
528 app.viewport.jump_to_latest_button_area,
529 MessageId::KbJumpTopBottom,
530 ),
531 (
532 app.viewport.last_plugin_cta_review_area,
533 MessageId::PluginCtaReview,
534 ),
535 (
536 app.viewport.last_plugin_cta_dismiss_area,
537 MessageId::KbCloseMenu,
538 ),
539 (
540 app.viewport.last_workflow_panel_area,
541 MessageId::CmdWorkflowDescription,
542 ),
543 ];
544 for (area, label) in targets {
545 let Some(area) = area else { continue };
546 crate::tui::hover_layer::register_rect(
547 crate::tui::hover_hit::HoverTargetKind::Link,
548 area,
549 codewhale_localization::tr(app.ui_locale, label).into_owned(),
550 false,
551 );
552 }
553
554 // The composer's `[↵]` submit control. It registers only when a click
555 // there would actually send: an affordance that lights up and then does
556 // nothing is the same defect as one that acts without lighting up.
557 if let Some(composer) = app.viewport.last_composer_area
558 && let Some(submit) = crate::tui::widgets::active_composer_submit_rect(app, composer)
559 && app.composer_enter_would_submit()
560 {
561 crate::tui::hover_layer::register_rect(
562 crate::tui::hover_hit::HoverTargetKind::Link,
563 submit,
564 codewhale_localization::tr(
565 app.ui_locale,
566 codewhale_localization::MessageId::KbSendDraft,
567 )
568 .into_owned(),
569 false,
570 );
571 }
572 }
573
574 /// Register the info line's drawn controls as one typed input surface.
575 ///
576 /// Both the launch stage and a live session use this exact registration, so
577 /// mouse routing cannot advertise a header segment on only one shell state.
578 fn register_info_interaction_targets(app: &mut App, hitboxes: InfoLineInteractionHitboxes) {
579 if let (Some(hitbox), Some(context_budget)) = (
580 hitboxes.context,
581 crate::tui::tideline::ContextBudgetSnapshot::from_app(app),
582 ) {
583 app.viewport
584 .interaction_targets
585 .register(crate::tui::tideline::InteractionTarget {
586 id: crate::tui::tideline::InteractionTargetId::HEADER_CONTEXT,
587 area: hitbox,
588 focus: crate::tui::tideline::InteractionFocus::Direct,
589 keyboard_action: Some(crate::tui::tideline::InteractionAction::InspectContext),
590 mouse_action: Some(crate::tui::tideline::InteractionAction::InspectContext),
591 inspect_detail: crate::tui::tideline::InspectDetail::ContextBudget(context_budget),
592 });
593 }
594 if let Some(hitbox) = hitboxes.route {
595 app.viewport
596 .interaction_targets
597 .register(crate::tui::tideline::InteractionTarget {
598 id: crate::tui::tideline::InteractionTargetId::HEADER_ROUTE,
599 area: hitbox,
600 focus: crate::tui::tideline::InteractionFocus::Direct,
601 keyboard_action: Some(crate::tui::tideline::InteractionAction::OpenProviderPicker),
602 mouse_action: Some(crate::tui::tideline::InteractionAction::OpenProviderPicker),
603 inspect_detail: crate::tui::tideline::InspectDetail::Route,
604 });
605 }
606 if let Some(hitbox) = hitboxes.model {
607 app.viewport
608 .interaction_targets
609 .register(crate::tui::tideline::InteractionTarget {
610 id: crate::tui::tideline::InteractionTargetId::HEADER_MODEL,
611 area: hitbox,
612 focus: crate::tui::tideline::InteractionFocus::Direct,
613 keyboard_action: Some(crate::tui::tideline::InteractionAction::OpenModelPicker),
614 mouse_action: Some(crate::tui::tideline::InteractionAction::OpenModelPicker),
615 inspect_detail: crate::tui::tideline::InspectDetail::Route,
616 });
617 }
618
619 for target in app.viewport.interaction_targets.iter() {
620 let label = match target.mouse_action {
621 Some(crate::tui::tideline::InteractionAction::InspectContext) => format!(
622 "{} · {}",
623 codewhale_localization::tr(
624 app.ui_locale,
625 codewhale_localization::MessageId::CtxMenuContextInspector,
626 ),
627 codewhale_localization::tr(
628 app.ui_locale,
629 codewhale_localization::MessageId::CtxMenuContextInspectorDesc,
630 ),
631 ),
632 Some(crate::tui::tideline::InteractionAction::OpenProviderPicker) => format!(
633 "{} · {}",
634 codewhale_localization::tr(
635 app.ui_locale,
636 codewhale_localization::MessageId::RoutePanelHeader,
637 ),
638 codewhale_localization::tr(
639 app.ui_locale,
640 codewhale_localization::MessageId::CmdProviderDescription,
641 ),
642 ),
643 // `/model` is a command name, not prose, so it stays verbatim in
644 // every locale; only the description is translated.
645 Some(crate::tui::tideline::InteractionAction::OpenModelPicker) => format!(
646 "/model · {}",
647 codewhale_localization::tr(
648 app.ui_locale,
649 codewhale_localization::MessageId::CmdModelDescription,
650 ),
651 ),
652 Some(crate::tui::tideline::InteractionAction::ShowDockPanel(panel)) => {
653 panel.title().to_string()
654 }
655 Some(crate::tui::tideline::InteractionAction::OpenAutomations) => {
656 "/automation".to_string()
657 }
658 Some(crate::tui::tideline::InteractionAction::DismissDock) => {
659 codewhale_localization::tr(
660 app.ui_locale,
661 codewhale_localization::MessageId::KbCloseMenu,
662 )
663 .into_owned()
664 }
665 None => continue,
666 };
667 crate::tui::hover_layer::register_rect(
668 crate::tui::hover_hit::HoverTargetKind::Link,
669 target.area,
670 label,
671 false,
672 );
673 }
674 }
675
676 /// The posture bar's live counts are the bottom-of-screen way into the
677 /// dock: each one opens the view it counts (agents → AGENTS, shells / tasks
678 /// → BACKGROUND, automations → their own view, the idle `todo` word → TODO).
679 /// Dock destinations use the same `ShowDockPanel` action as the strip's tabs.
680 fn register_footer_count_targets(
681 app: &mut App,
682 facts: &crate::tui::phase_strip::TidelineFooterFacts,
683 count_rects: &[(usize, Rect)],
684 ) {
685 for (index, area) in count_rects {
686 let Some(action) = facts.count_actions.get(*index).copied() else {
687 continue;
688 };
689 app.viewport
690 .interaction_targets
691 .register(crate::tui::tideline::InteractionTarget {
692 id: crate::tui::tideline::InteractionTargetId::FOOTER_COUNT,
693 area: *area,
694 focus: crate::tui::tideline::InteractionFocus::Direct,
695 keyboard_action: Some(action),
696 mouse_action: Some(action),
697 inspect_detail: crate::tui::tideline::InspectDetail::Route,
698 });
699 }
700 }
701
702 /// Map the host terminal rect onto the session shell canvas.
703 ///
704 /// Wide terminals use the full available width (v0.8.65 behavior; #5322). A
705 /// brief v0.9 gutter capped usable columns beyond 112 and left dead margins on
706 /// large displays / tmux panes; that cap is gone. Keep this helper so layout
707 /// and PTY oracles share one geometry entry point if a future setting wants a
708 /// configurable measure again.
709 pub(crate) fn session_shell_area(area: Rect) -> Rect {
710 area
711 }
712
713 /// Snapshot the posture a real `Op::SendMessage` would carry, and — when the
714 /// user supplied a hypothetical prompt — resolve the next turn's route with
715 /// the **same shared planner** dispatch uses (#1004).
716 ///
717 /// The hypothetical prompt is taken through the deterministic part of the real
718 /// submit path, in the real order: the **active skill** it would be wrapped
719 /// with, file and git mention resolution with the same error propagation, and
720 /// the paused-command note a real submit appends. That is what makes the body
721 /// the engine hashes the body a real turn would build. It is never added to
722 /// the conversation, no state is consumed, and the previewed request itself is
723 /// never sent.
724 ///
725 /// Two things a real submit does that an inspection must not, and what happens
726 /// instead:
727 ///
728 /// - **`message_submit` hooks.** They run first, before mentions, skill
729 /// wrapping, route planning, and the tool policy, and they may replace the
730 /// text or block the turn outright. Running them would give a *preview* the
731 /// side effects of a submit. So when any are configured, nothing downstream
732 /// of the text can be claimed exact and the whole manifest reports
733 /// [`crate::core::engine::preview::PreviewUnresolved::MessageSubmitHooksConfigured`] —
734 /// including under a
735 /// fixed model, because the tool policy is derived from the content too.
736 /// - **Consuming the active skill.** A real submit *takes* `app.active_skill`.
737 /// The preview clones it: the skill is still pending after an inspection,
738 /// and the previewed body is the one it would have produced. Dropping it
739 /// instead — which the first pass did — previewed an unwrapped prompt and
740 /// quietly under-reported the request by the whole skill instruction.
741 ///
742 /// Without a prompt there is no next-turn route to resolve under auto model
743 /// routing and no next-turn body under any routing, so this reports a typed
744 /// unresolved state instead of recycling the installed route.
745 pub(crate) async fn build_preview_request_inputs(
746 app: &App,
747 config: &Config,
748 engine_handle: &EngineHandle,
749 hypothetical_prompt: Option<String>,
750 ) -> crate::core::engine::preview::PreviewRequestInputs {
751 use crate::core::engine::preview::{PreviewNextTurn, PreviewRequestInputs, PreviewUnresolved};
752
753 let requested_model = if app.auto_model {
754 "auto".to_string()
755 } else {
756 app.model.clone()
757 };
758 let prompt_supplied = hypothetical_prompt.is_some();
759 let posture = |next_turn, unresolved| PreviewRequestInputs {
760 mode: app.mode,
761 allow_shell: app.allow_shell,
762 trust_mode: app.trust_mode,
763 auto_approve: app_auto_approve_enabled(app),
764 approval_mode: app.approval_mode,
765 allowed_tools: app.active_allowed_tools.clone(),
766 dynamic_tools: Vec::new(),
767 provenance: crate::core::ops::UserInputProvenance::ExternalUser,
768 requested_model: requested_model.clone(),
769 requested_reasoning: app.reasoning_effort.as_setting().to_string(),
770 auto_model: app.auto_model,
771 hypothetical_prompt_supplied: prompt_supplied,
772 next_turn,
773 unresolved,
774 };
775
776 let Some(prompt) = hypothetical_prompt else {
777 // Never clear the unresolved flag just because a session has a route:
778 // under auto routing the next prompt is what decides it.
779 return posture(
780 None,
781 if app.auto_model {
782 PreviewUnresolved::AutoRouteNeedsPrompt
783 } else {
784 PreviewUnresolved::NoPrompt
785 },
786 );
787 };
788
789 // Auto routing runs a model classifier. `/preview-request` is an offline
790 // inspection command, so it stops before prompt resolution or the shared
791 // planner can reach that call. Production remains responsible for Auto.
792 if auto_router::should_resolve_auto_model_selection(app) {
793 return posture(None, PreviewUnresolved::AutoRouteClassificationNotExecuted);
794 }
795
796 if app
797 .hooks
798 .has_hooks_for_event(crate::hooks::HookEvent::MessageSubmit)
799 {
800 return posture(None, PreviewUnresolved::MessageSubmitHooksConfigured);
801 }
802
803 // Clone, never `take`: an inspection may not consume the pending skill.
804 let message = QueuedMessage {
805 display: prompt.clone(),
806 skill_instruction: app.active_skill.clone(),
807 skill_provenance: app.active_skill_provenance.clone(),
808 history_echoed: false,
809 };
810 let mut git_cache = crate::tui::git_mention::GitMentionCache::default();
811 // Same failure surface as a real submit: a plugin-skill authority mismatch
812 // aborts the turn there and must not be papered over with the raw prompt
813 // here — that would describe a request the user could not send.
814 let mut content = match queued_message_content_for_app(
815 app,
816 &message,
817 std::env::current_dir().ok(),
818 &mut git_cache,
819 ) {
820 Ok(content) => content,
821 Err(error) => {
822 return posture(
823 None,
824 PreviewUnresolved::PromptResolutionFailed(error.to_string()),
825 );
826 }
827 };
828 // A real submit appends the paused-command note before planning the route.
829 // `plan_paused_command_message` is pure — it decides, it does not resume or
830 // discard anything — so the preview can use the same value.
831 let paused_dispatch = plan_paused_command_message(app, &prompt);
832 if let Some(note) = paused_dispatch.note() {
833 content.push_str(note);
834 }
835
836 let (app_route_identity, route_config) = app_scoped_runtime_config(app, config);
837 let planned = plan_turn_route(TurnRoutePlanRequest {
838 route_config: &route_config,
839 app_route_identity: &app_route_identity,
840 api_provider: app.api_provider,
841 app_model: &app.model,
842 auto_model: app.auto_model,
843 reasoning_effort: app.reasoning_effort,
844 mode: app.mode,
845 content: &content,
846 auto_router_context: &auto_router::recent_auto_router_context(&app.api_messages),
847 should_auto_resolve: false,
848 allow_auto_router_response_cache: false,
849 preflight_required: engine_handle.client_preflight_required(),
850 auto_compact_user_configured: app.auto_compact_user_configured,
851 auto_compact: app.auto_compact,
852 auto_compact_threshold_percent: app.auto_compact_threshold_percent,
853 })
854 .await;
855
856 match planned {
857 Ok(planned) => {
858 let prompt_context = crate::core::engine::NextTurnPromptContext::for_planned_turn(
859 planned.route.identity.provider,
860 planned.route.model.clone(),
861 crate::route_budget::known_route_limits(planned.route.candidate.limits()),
862 app.mode,
863 paused_dispatch.goal_objective(app),
864 app.goal.status,
865 app.goal.token_budget,
866 app.translation_enabled,
867 app.verbosity.clone(),
868 );
869 posture(
870 Some(Box::new(PreviewNextTurn {
871 content,
872 route: Box::new(planned.route),
873 prompt_context,
874 reasoning_effort: planned.effective_reasoning_effort,
875 reasoning_effort_auto: planned.auto_controls_reasoning,
876 auto_route_source: planned
877 .auto_selection
878 .as_ref()
879 .map(|selection| selection.source.label().to_string()),
880 routing_source: planned.routing_source,
881 compaction: planned.compaction,
882 })),
883 PreviewUnresolved::NoPrompt,
884 )
885 }
886 Err(error) => posture(None, PreviewUnresolved::PlanFailed(error)),
887 }
888 }
889
890 pub(crate) fn build_engine_config(app: &App, config: &Config) -> EngineConfig {
891 let provider = app.api_provider;
892 let max_subagents = app.max_subagents.clamp(1, crate::config::MAX_SUBAGENTS);
893 EngineConfig {
894 model: app.model.clone(),
895 active_route_limits: app.active_route_limits,
896 workspace: app.workspace.clone(),
897 // The App owns the session id (claimed before the Runtime store lock
898 // and used for every checkpoint/autosave); the engine adopts it so the
899 // engine conversation and the persisted session are the same record.
900 session_id: app.current_session_id.clone(),
901 subagent_state_root: None,
902 allow_shell: app.allow_shell,
903 trust_mode: app.trust_mode,
904 notes_path: config.notes_path(),
905 mcp_config_path: config.mcp_config_path(),
906 mcp_oauth_callback_port: config.mcp_oauth_callback_port,
907 mcp_oauth_callback_url: config.mcp_oauth_callback_url.clone(),
908 skills_dir: app.skills_dir.clone(),
909 skills_scan_codewhale_only: app.skills_scan_codewhale_only,
910 plugin_registry: Some(std::sync::Arc::clone(&app.plugin_registry)),
911 instructions: configured_instruction_sources(config),
912 project_context_pack_enabled: config.project_context_pack_enabled(),
913 translation_enabled: app.translation_enabled,
914 verbosity: app.verbosity.clone(),
915 // Only an explicit `[tui].max_model_steps` installs a step ceiling.
916 max_steps: config.max_model_steps(),
917 max_subagents,
918 max_admitted_subagents: config
919 .max_admitted_subagents_for_provider(provider)
920 .max(max_subagents),
921 launch_concurrency: config
922 .launch_concurrency_for_provider(provider)
923 .max(app.mode.mode_delegation_launch_floor()),
924 subagents_enabled: config.subagents_enabled_for_provider(provider),
925 features: config.features(),
926 auto_review_policy: config.auto_review_policy(),
927 compaction: app.compaction_config(),
928 todos: app.todos.clone(),
929 plan_state: app.plan_state.clone(),
930 goal_state: app.last_known_goal_state.as_ref().map_or_else(
931 || {
932 crate::tools::goal::new_shared_goal_state_from_host_status(
933 app.goal.objective.clone(),
934 app.goal.token_budget,
935 app.goal.status,
936 )
937 },
938 |goal| {
939 crate::tools::goal::new_shared_goal_state_from_snapshot(&goal.to_runtime_snapshot())
940 },
941 ),
942 max_spawn_depth: config.subagent_max_spawn_depth_for_provider(provider),
943 allowed_tools: app.active_allowed_tools.clone(),
944 disallowed_tools: None,
945 max_tool_calls: None,
946 hook_executor: app.runtime_services.hook_executor.clone(),
947 network_policy: config.network.clone().map(|toml_cfg| {
948 crate::network_policy::NetworkPolicyDecider::with_default_audit(toml_cfg.into_runtime())
949 }),
950 snapshots_enabled: config.snapshots_config().enabled,
951 snapshots_max_workspace_bytes: config
952 .snapshots_config()
953 .max_workspace_gb
954 .saturating_mul(1024 * 1024 * 1024),
955 lsp_config: config
956 .lsp
957 .clone()
958 .map(crate::config::LspConfigToml::into_runtime),
959 runtime_services: app.runtime_services.clone(),
960 subagent_model_overrides: config.subagent_model_overrides(),
961 fleet_roster: std::sync::Arc::new(crate::fleet::identity::load_effective_roster(
962 &config.fleet_config(),
963 &app.workspace,
964 Some(app.plugin_registry.as_ref()),
965 )),
966 subagent_api_timeout: Duration::from_secs(
967 config.subagent_api_timeout_secs_for_provider(provider),
968 ),
969 stream_chunk_timeout: Duration::from_secs(app.stream_chunk_timeout_secs),
970 turn_wall_clock: config.turn_wall_clock(),
971 stream_max_content_bytes: config.stream_max_content_bytes(),
972 stream_max_duration: config.stream_max_duration(),
973 subagent_heartbeat_timeout: Duration::from_secs(
974 config.subagent_heartbeat_timeout_secs_for_provider(provider),
975 ),
976 prefer_bwrap: config.prefer_bwrap.unwrap_or(false),
977 bwrap_extensions: crate::sandbox::BwrapMountExtensions {
978 read_only_roots: config.bwrap_ro_roots.clone(),
979 device_roots: config.bwrap_dev_roots.clone(),
980 },
981 read_denylist: config.read_denylist(),
982 memory_enabled: config.memory_enabled(),
983 memory_path: config.memory_path(),
984 speech_output_dir: config.speech_output_dir(),
985 vision_config: config.vision_model_config(),
986 strict_tool_mode: config.strict_tool_mode.unwrap_or(false),
987 goal_objective: app.goal.objective.clone(),
988 goal_token_budget: app.goal.token_budget,
989 goal_status: app.goal.status,
990 goal_max_continuations: config.goal_max_continuations(),
991 goal_continuation_delay_seconds: config.goal_continuation_delay_seconds(),
992 goal_enforce_token_budget: config.goal_enforce_token_budget(),
993 reasoning_only_max_reprompts: config.reasoning_only_max_reprompts(),
994 reasoning_only_reprompt_message: Some(config.reasoning_only_reprompt_message().to_string()),
995 locale_tag: app.ui_locale.tag().to_string(),
996 workshop: {
997 crate::tools::large_output_router::WorkshopConfig::install_active(
998 config.workshop.as_ref(),
999 );
1000 config.workshop.clone()
1001 },
1002 search_provider: config.search_provider(),
1003 search_api_key: config.search.as_ref().and_then(|s| s.api_key.clone()),
1004 search_base_url: config.search.as_ref().and_then(|s| s.base_url.clone()),
1005 tools_always_load: config.tools_always_load(),
1006 user_input_limits: config.user_input_limits(),
1007 user_input_timeout: config.user_input_timeout(),
1008 goal_max_steps: Some(config.goal_max_steps()),
1009 tools: config.tools.clone(),
1010 workspace_follow_symlinks: app.workspace_follow_symlinks,
1011 exec_policy_engine: config.exec_policy_engine.clone(),
1012 terminal_chrome_enabled: true,
1013 advisor_config: config
1014 .advisor
1015 .as_ref()
1016 .map(crate::tools::subagent::AdvisorConfig::from_toml)
1017 .unwrap_or_else(crate::tools::subagent::AdvisorConfig::disabled),
1018 }
1019 }
1020
1021 #[cfg(test)]
1022 pub(crate) fn build_app_system_prompt(app: &App, config: &Config) -> SystemPrompt {
1023 build_app_system_prompt_with_goal(app, config, app.goal.objective.as_deref())
1024 }
1025
1026 pub(crate) fn build_app_system_prompt_with_goal(
1027 app: &App,
1028 config: &Config,
1029 goal_objective: Option<&str>,
1030 ) -> SystemPrompt {
1031 let instructions = configured_instruction_sources(config);
1032 let user_memory_block = crate::native_memory::native_prompt_block(
1033 config.memory_enabled(),
1034 &config.memory_path(),
1035 &app.workspace,
1036 );
1037 // Keep the previewed/rebuilt prompt identical to the engine's: the
1038 // recovery hint is part of the prefix when a prior workspace session
1039 // ended mid-turn (#5715).
1040 let recovery_hint = crate::session_manager::session_recovery_hint(
1041 &app.workspace,
1042 app.current_session_id.as_deref(),
1043 );
1044 prompts::system_prompt_for_mode_with_context_skills_and_session(
1045 &app.workspace,
1046 None,
1047 Some(&app.skills_dir),
1048 Some(&instructions),
1049 prompts::PromptSessionContext {
1050 user_memory_block: user_memory_block.as_deref(),
1051 goal_objective,
1052 project_context_pack_enabled: config.project_context_pack_enabled(),
1053 locale_tag: app.ui_locale.tag(),
1054 translation_enabled: app.translation_enabled,
1055 model_id: &app.model,
1056 context_window_override: Some(crate::route_budget::route_context_window_tokens(
1057 app.api_provider,
1058 &app.model,
1059 app.active_route_limits,
1060 )),
1061 verbosity: app.verbosity.as_deref(),
1062 recovery_hint: recovery_hint.as_deref(),
1063 skills_scan_codewhale_only: app.skills_scan_codewhale_only,
1064 plugin_registry: Some(app.plugin_registry.as_ref()),
1065 mode: app.mode,
1066 },
1067 )
1068 }
1069
1070 /// Build the session snapshot every product caller queues into the
1071 /// persistence actor. Journal-only (#6214 T3): the `messages` projection is
1072 /// left empty because the queue drops it anyway, and serialization rehydrates
1073 /// it from the journal — the on-disk bytes are unchanged. Callers must not
1074 /// read `.messages` off the returned snapshot; save or serialize it.
1075 pub(crate) fn build_session_snapshot(
1076 app: &mut App,
1077 manager: &SessionManager,
1078 ) -> Result<SavedSession, String> {
1079 let model = app.model_selection_for_persistence();
1080 let work_state = match app.try_work_state_snapshot() {
1081 Ok(work_state) => work_state,
1082 Err(err) => app.last_known_work_state.clone().ok_or_else(|| {
1083 format!("automatic session snapshot skipped while Work state is busy: {err}")
1084 })?,
1085 };
1086 app.session_journal
1087 .rebranch_active_messages_stamped(&app.api_messages, &app.api_message_stamps);
1088 let mut session = crate::session_manager::create_saved_session_journal_only(
1089 app.current_session_id
1090 .clone()
1091 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
1092 &app.api_messages,
1093 app.session_journal.clone(),
1094 &model,
1095 &app.workspace,
1096 u64::from(app.session.total_tokens),
1097 app.system_prompt.as_ref(),
1098 Some(app.mode.as_setting()),
1099 );
1100 let computed_title = session.metadata.title.clone();
1101 if let Some(cached) = app
1102 .current_session_metadata
1103 .as_ref()
1104 .filter(|cached| cached.id == session.metadata.id)
1105 {
1106 session.metadata.created_at = cached.created_at;
1107 session
1108 .metadata
1109 .parent_session_id
1110 .clone_from(&cached.parent_session_id);
1111 session.metadata.forked_from_message_count = cached.forked_from_message_count;
1112 session.metadata.archived = cached.archived;
1113 session
1114 .metadata
1115 .runtime_store
1116 .clone_from(&cached.runtime_store);
1117 }
1118 // The cache above is a hint; disk is the authority for lifecycle state.
1119 // Re-reading here is what makes "an archive or rename cannot be reverted
1120 // by autosave" true regardless of which surface applied it or when
1121 // (#2934 / #4397). One bounded metadata-prefix read, not a transcript scan.
1122 let merged = manager.merge_persisted_lifecycle(&mut session.metadata);
1123 if let Some(binding) = app
1124 .runtime_services
1125 .task_manager
1126 .as_ref()
1127 .and_then(|tasks| tasks.session_store_binding())
1128 {
1129 if session
1130 .metadata
1131 .runtime_store
1132 .as_ref()
1133 .is_some_and(|saved| {
1134 saved != &binding && !saved.is_missing_session_store().unwrap_or(false)
1135 })
1136 {
1137 return Err(
1138 "session snapshot refused to replace its saved Runtime store ownership".into(),
1139 );
1140 }
1141 session.metadata.runtime_store = Some(binding);
1142 }
1143 // Title resolution, in priority order:
1144 // 1. Disk, when the session already exists (#2934/#4397: a rename applied
1145 // through the session manager is persisted and must survive autosave).
1146 // 2. The in-memory cache, when there is no disk record for the session
1147 // yet. (The session picker normally persists renames to disk first via
1148 // `rename_selected`; this branch covers sessions that have never been
1149 // saved, where the cache is the only title source.)
1150 // 3. The title computed from the conversation (first user message).
1151 // The cache is NOT a candidate on its own: it is only refreshed at the
1152 // end of this function, so a snapshot taken before any user message
1153 // pins it to the `DEFAULT_SESSION_TITLE` placeholder, and restoring it
1154 // would prevent every later title update (the bug this block fixes).
1155 if !merged
1156 && let Some(cached) = app.current_session_metadata.as_ref()
1157 && cached.id == session.metadata.id
1158 {
1159 session.metadata.title.clone_from(&cached.title);
1160 }
1161 if session.metadata.title == crate::session_manager::DEFAULT_SESSION_TITLE
1162 && computed_title != crate::session_manager::DEFAULT_SESSION_TITLE
1163 {
1164 // The placeholder survived from an earlier snapshot; the conversation
1165 // now has a real first user message, so let the computed title win.
1166 // Known edge: a session deliberately renamed to the literal
1167 // placeholder title is treated the same way and yields to the
1168 // computed title on the next snapshot.
1169 session.metadata.title = computed_title;
1170 }
1171 if let Some(cached) = app.current_session_metadata.as_mut()
1172 && cached.id == session.metadata.id
1173 {
1174 cached.title.clone_from(&session.metadata.title);
1175 cached.archived = session.metadata.archived;
1176 }
1177 session
1178 .metadata
1179 .set_model_provider_route(app.api_provider.as_str(), app.provider_id_for_persistence());
1180 app.sync_cost_to_metadata(&mut session.metadata);
1181 session.context_references = app.session_context_references.clone();
1182 session.artifacts = app.session_artifacts.clone();
1183 session.work_state = work_state;
1184 session.last_auto_route = app.auto_route_for_persistence();
1185 session.window_title.clone_from(&app.window_title);
1186 app.current_session_metadata = Some(session.metadata.clone());
1187 // Claim ownership of this session for the process. From here on the
1188 // Runtime API refuses external renames/archives of it with a typed 409
1189 // rather than writing something the next snapshot would revert.
1190 //
1191 // Claiming here rather than at each of the ten `current_session_id`
1192 // assignment sites is deliberate: this is the function that establishes
1193 // "the TUI holds the authoritative copy", which is exactly the condition
1194 // the conflict protects. A session that has never been snapshotted has no
1195 // in-memory state to lose, so leaving it unclaimed is correct, not a gap.
1196 crate::session_manager::set_live_session(Some(&session.metadata.id));
1197 Ok(session)
1198 }
1199
1200 /// Strip ANSI control codes / non-printable bytes from a streaming
1201 /// text chunk. `pub(super)` because `tui::notifications` consumes it
1202 /// from `crate::tui::ui` for its per-turn message composition.
1203 pub(crate) fn sanitize_stream_chunk(chunk: &str) -> String {
1204 // Keep printable characters and common whitespace; drop control bytes.
1205 chunk
1206 .chars()
1207 .filter(|c| *c == '\n' || *c == '\t' || !c.is_control())
1208 .collect()
1209 }
1210
1211 /// Ensure an in-flight streaming Assistant cell exists in history and return
1212 /// its index. Thinking cells go through `streaming_thinking::ensure_active_entry`
1213 /// (active cell) instead.
1214 pub(crate) fn ensure_streaming_assistant_history_cell(app: &mut App) -> usize {
1215 if let Some(index) = app.streaming_message_index {
1216 return index;
1217 }
1218 app.add_message(HistoryCell::Assistant {
1219 content: String::new(),
1220 streaming: true,
1221 });
1222 let index = app.history.len().saturating_sub(1);
1223 app.streaming_message_index = Some(index);
1224 index
1225 }
1226
1227 pub(crate) fn append_streaming_text(app: &mut App, index: usize, text: &str) {
1228 if text.is_empty() {
1229 return;
1230 }
1231 app.resync_history_revisions();
1232 let Some(previous_revision) = app.history_revisions.get(index).copied() else {
1233 return;
1234 };
1235 let chained_from_revision = app
1236 .streaming_source_receipt
1237 .filter(|receipt| receipt.cell_index == index && receipt.to_revision == previous_revision)
1238 .map_or(previous_revision, |receipt| receipt.from_revision);
1239 let mut content_len = None;
1240 if let Some(HistoryCell::Assistant { content, .. }) = app.history.get_mut(index) {
1241 content.push_str(text);
1242 content_len = Some(content.len());
1243 // Bump only the streaming cell's per-cell revision so the transcript
1244 // cache re-renders just this cell. Without this, the cache would
1245 // either skip the update entirely (now that the global
1246 // history_version is no longer fanned out across every cell) or fall
1247 // back to a full re-wrap of the entire transcript every chunk.
1248 app.bump_history_cell(index);
1249 }
1250 let Some(content_len) = content_len else {
1251 return;
1252 };
1253 if let Some(to_revision) = app.history_revisions.get(index).copied() {
1254 app.streaming_source_receipt = Some(crate::tui::transcript::StreamingSourceReceipt {
1255 cell_index: index,
1256 from_revision: chained_from_revision,
1257 to_revision,
1258 content_len,
1259 });
1260 }
1261 }
1262
1263 pub(crate) fn accrue_streaming_token_estimate(app: &mut App, visible_text: &str) {
1264 if visible_text.is_empty() {
1265 return;
1266 }
1267 app.streaming_output_token_estimate = app
1268 .streaming_output_token_estimate
1269 .saturating_add(estimate_output_tokens_from_text(visible_text));
1270 }
1271
1272 pub(crate) fn commit_streaming_display_tick(
1273 app: &mut App,
1274 stream_display_clock: &mut StreamDisplayClock,
1275 now: Instant,
1276 ) -> bool {
1277 if !stream_display_clock.take_due(now) {
1278 return false;
1279 }
1280
1281 // Reveal a bounded slice per beat rather than everything received. The
1282 // budget is sized from the beat and the backlog, so the displayed pace is a
1283 // function of the clock instead of the provider's chunking.
1284 let interval = stream_display_clock.interval();
1285 let mut updated = false;
1286 if let Some(index) = app.streaming_message_index {
1287 let budget =
1288 crate::tui::streaming::reveal_budget(interval, app.streaming_state.pending_len(0));
1289 let committed = app.streaming_state.commit_text(0, budget);
1290 if !committed.is_empty() {
1291 append_streaming_text(app, index, &committed);
1292 accrue_streaming_token_estimate(app, &committed);
1293 updated = true;
1294 }
1295 } else if let Some(entry_idx) = app.streaming_thinking_active_entry {
1296 let budget =
1297 crate::tui::streaming::reveal_budget(interval, app.streaming_state.pending_len(0));
1298 let committed = app.streaming_state.commit_text(0, budget);
1299 if !committed.is_empty() {
1300 if app.translation_enabled {
1301 streaming_thinking::set_placeholder(app, entry_idx);
1302 } else {
1303 streaming_thinking::append(app, entry_idx, &committed);
1304 }
1305 updated = true;
1306 }
1307 }
1308
1309 if app.streaming_state.has_pending_stream_text(0) {
1310 stream_display_clock.note_delta(now);
1311 }
1312
1313 updated
1314 }
1315
1316 pub(crate) fn live_tool_receipt_messages(
1317 app: &App,
1318 id: &str,
1319 raw: &str,
1320 success: bool,
1321 ) -> Vec<Message> {
1322 let mut messages = Vec::with_capacity(2);
1323 if let Some(tool_use_msg) = app.api_messages.iter().rev().find(|message| {
1324 message.content.iter().any(|block| {
1325 matches!(block, ContentBlock::ToolUse { id: tool_use_id, ..} if tool_use_id == id)
1326 })
1327 }) {
1328 messages.push(tool_use_msg.clone());
1329 }
1330 messages.push(Message {
1331 role: Role::User,
1332 content: vec![ContentBlock::ToolResult {
1333 tool_use_id: id.to_string(),
1334 content: raw.to_string(),
1335 is_error: Some(!success),
1336 content_blocks: None,
1337 }],
1338 });
1339 messages
1340 }
1341
1342 pub(crate) fn compact_live_tool_receipt(
1343 messages: Vec<Message>,
1344 artifacts: Vec<crate::artifacts::ArtifactRecord>,
1345 raw: String,
1346 ) -> Option<String> {
1347 let (compacted, _) =
1348 crate::tool_output_receipts::compact_messages_for_persistence(&messages, &artifacts);
1349 let content = compacted
1350 .last()
1351 .and_then(|message| message.content.first())
1352 .and_then(|block| match block {
1353 ContentBlock::ToolResult { content, .. } => Some(content),
1354 _ => None,
1355 })?;
1356 if content != &raw && live_tool_content_is_receipt(content) {
1357 Some(content.clone())
1358 } else {
1359 None
1360 }
1361 }
1362
1363 pub(crate) fn live_tool_content_is_receipt(content: &str) -> bool {
1364 content.trim_start().starts_with("[TOOL_OUTPUT_RECEIPT]")
1365 }
1366
1367 /// Build the pending-input preview widget from current `App` state.
1368 ///
1369 /// v0.6.6 (#122) wires the live buckets:
1370 /// - `pending_steers` — typed during a running turn + Esc; held until the
1371 /// abort lands and gets resubmitted as a fresh merged turn.
1372 /// - `queued_messages` — Enter while busy; drained at end-of-turn. An
1373 /// unaccepted steer also lands here (#6297) so it is never lost. In Operate,
1374 /// the foreground operator dispatches these as additional background tasks.
1375 pub(crate) fn build_pending_input_preview(app: &App) -> PendingInputPreview {
1376 let mut preview = PendingInputPreview::new();
1377 preview.locale = app.ui_locale;
1378 let selected_attachment = app.selected_composer_attachment_index();
1379 let mut attachment_index = 0usize;
1380 preview.context_items = crate::tui::file_mention::pending_context_previews(&app.input)
1381 .into_iter()
1382 .map(|item| {
1383 let selected = if item.removable {
1384 let selected = selected_attachment == Some(attachment_index);
1385 attachment_index += 1;
1386 selected
1387 } else {
1388 false
1389 };
1390 ContextPreviewItem {
1391 kind: item.kind,
1392 label: item.label,
1393 detail: item.detail,
1394 included: item.included,
1395 removable: item.removable,
1396 selected,
1397 }
1398 })
1399 .collect();
1400 // #6190: a steer the engine has not recorded yet is exactly what this
1401 // bucket's "sending into turn" label describes, so it shares it rather
1402 // than growing a fourth bucket and a fifteenth locale string.
1403 preview.pending_steers = app
1404 .pending_steers
1405 .iter()
1406 .chain(app.inflight_steers.iter().map(|steer| &steer.message))
1407 .map(|m| m.display.clone())
1408 .collect();
1409 preview.queued_messages = app
1410 .queued_messages
1411 .iter()
1412 .map(|m| m.display.clone())
1413 .collect();
1414 preview.editing_queued_message = app.queued_draft.as_ref().map(|draft| {
1415 if app.input.trim().is_empty() {
1416 draft.display.clone()
1417 } else {
1418 app.input.clone()
1419 }
1420 });
1421 preview
1422 }
1423
1424 pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<(u16, u16)> {
1425 let size = f.area();
1426 // Hover targets belong to the whole composed frame. Resetting inside the
1427 // transcript erased targets registered later by the composer and modals.
1428 crate::tui::hover_layer::begin_frame();
1429 app.pet_watch.prepare_frame();
1430 let shell_area = session_shell_area(size);
1431 // Keep the view stack's focus-context texture prototype (#4823) in step
1432 // with the parsed setting each frame: a plain enum/theme copy, no
1433 // allocation. `Off` leaves the render byte-identical to before.
1434 app.view_stack
1435 .set_focus_texture(app.focus_texture, app.ui_theme);
1436 app.sidebar_hover = crate::tui::app::SidebarHoverState::default();
1437 app.viewport.last_prompt_area = None;
1438 app.viewport.interaction_targets.clear();
1439 // Keep the OSC-0 whale title truthful to the current shell phase so
1440 // alt-tabbed sessions communicate state without a second in-app spinner.
1441 crate::tui::underwater::sync_title_activity(app);
1442
1443 // Clear entire area with the configured app background.
1444 let background = Block::default().style(Style::default().bg(app.ui_theme.surface_bg));
1445 f.render_widget(background, size);
1446
1447 // Show onboarding screen if needed
1448 if app.onboarding != OnboardingState::None {
1449 onboarding::render(f, size, app);
1450 // Onboarding is a backdrop, not a separate screen manager. Render any
1451 // native view above every onboarding step so shared pickers and the
1452 // first-run privacy disclosure cannot become invisible outside the
1453 // Provider step.
1454 if !app.view_stack.is_empty() {
1455 let buf = f.buffer_mut();
1456 app.view_stack.render(size, buf);
1457 }
1458 return None;
1459 }
1460
1461 // The opening screen is no longer a separate surface. Founder ruling:
1462 // "we don't have to have a different look for the opening screen ... we
1463 // can make it an asset that exists there instead". The launch card is now
1464 // the idle transcript's own empty state (`underwater::launch_empty_state`),
1465 // so the composer below it is the real one, the footer and info line are
1466 // the ones every other screen wears, and Tab means what it means
1467 // everywhere else — there is no second input authority left to arbitrate.
1468
1469 // The `[redaction] model_bound` opt-out gate owns the first screen too:
1470 // it must be answered before any session starts, and it renders above the
1471 // launch surface.
1472 if app.redaction_gate {
1473 crate::tui::redaction_gate::render(f, size, app);
1474 return None;
1475 }
1476 if app.view_stack.top_kind() == Some(crate::tui::views::ModalKind::PetHabitat) {
1477 crate::tui::pet_watch::render_full(f, app);
1478 return None;
1479 }
1480
1481 // Mini-window mode: when the host terminal window is pinned into its
1482 // small always-on-top form, hide the shell chrome and keep only what the
1483 // user opted to keep (`[mini_window]` in config.toml, or mutated live by
1484 // `/config mini_window.keep_*`). The message stream takes the rest.
1485 let mini = crate::tui::window_control::pinned();
1486 let mini_cfg = app.mini_window.clone();
1487 // The info line owns the shell's last row as exactly one row (spec §5b:
1488 // `Constraint::Length(1)`). It used to be the header; the founder moved
1489 // it to the bottom (SHELL-DESIGN-20260901 §2.0) so scrolling up reads as
1490 // intentional. `keep_header` still governs it in mini mode — the row it
1491 // names moved, not the preference.
1492 // Evaluate the fully-idle predicate exactly once per frame. It decides
1493 // how many rows the rail may reserve and whether the idle ocean draws
1494 // its brand mark (in ChatWidget); calling it twice would let the
1495 // reservation and the render disagree inside a single frame.
1496 let idle_empty = crate::tui::widgets::should_render_empty_state(app);
1497 // `tui.metrics_line = "hidden"` gives the row to the transcript (#5950).
1498 // The empty shell keeps route identity visible; render_info_row omits
1499 // session readings until a conversation exists.
1500 let info_height = if (mini && !mini_cfg.keep_header)
1501 || app.metrics_line == crate::config::ChromeRowPreset::Hidden
1502 {
1503 0
1504 } else {
1505 info_row_height_for(size.height)
1506 };
1507 // The merged Tideline footer is the single bottom row (spec §3: slots
1508 // 6+8 collapsed; §5b `Constraint::Length(1)`): phase·cost·posture on the
1509 // left, depth·keys on the right. It hides with the rest of the footer
1510 // chrome in mini mode, never with the composer.
1511 // `tui.posture_bar = "hidden"` likewise (#5950).
1512 let footer_height = if (mini && !mini_cfg.keep_footer)
1513 || app.posture_bar == crate::config::ChromeRowPreset::Hidden
1514 {
1515 0
1516 } else {
1517 crate::tui::phase_strip::height()
1518 };
1519 let slash_menu_entries = visible_slash_menu_entries(app, SLASH_MENU_LIMIT);
1520 let mention_menu_limit = app.mention_menu_limit;
1521 let mention_menu_entries =
1522 crate::tui::file_mention::visible_mention_menu_entries(app, mention_menu_limit);
1523 if !mention_menu_entries.is_empty() && app.mention_menu_selected >= mention_menu_entries.len() {
1524 app.mention_menu_selected = mention_menu_entries.len().saturating_sub(1);
1525 }
1526 let rail_budget = rail_row_budget(app, shell_area.width, shell_area.height, idle_empty);
1527 let top_work_strip_height = if mini && !mini_cfg.keep_todo {
1528 // Mini mode hides the strip; when the side rail is also hidden (the
1529 // default), drop the work-surface interaction state so stale
1530 // hitboxes from the pre-pin layout cannot swallow transcript clicks
1531 // or trigger phantom strip actions (review M1). A visible rail/strip
1532 // refreshes that state during its own render.
1533 if !mini_cfg.keep_sidebar {
1534 crate::tui::work_surface::collapse_strip(app);
1535 }
1536 0
1537 } else {
1538 crate::tui::work_surface::height(app, shell_area.width, shell_area.height, rail_budget)
1539 };
1540
1541 // Nothing paints above the stage any more, so the body is the whole
1542 // shell area. The old two-pass split existed only to pin a header to row
1543 // zero against ratatui's Flex defaults (#1834); with no header there is
1544 // nothing to pin.
1545 let body_area = shell_area;
1546
1547 let body_height = body_area.height;
1548 let composer_max_height = body_height
1549 .saturating_sub(
1550 MIN_CHAT_HEIGHT
1551 .saturating_add(footer_height)
1552 .saturating_add(info_height)
1553 .saturating_add(top_work_strip_height),
1554 )
1555 .max(MIN_COMPOSER_HEIGHT);
1556 let composer_height = if mini && !mini_cfg.keep_input {
1557 0
1558 } else {
1559 let composer_widget = ComposerWidget::new(
1560 app,
1561 composer_max_height,
1562 &slash_menu_entries,
1563 &mention_menu_entries,
1564 );
1565 composer_widget.desired_height(shell_area.width)
1566 };
1567
1568 // Pending-input preview (queued / steered messages). Empty when nothing's
1569 // queued, so zero height when idle. Phase 2 of #85 — solves the
1570 // "messages typed during a running turn vanish" complaint by giving the
1571 // user immediate visible feedback above the composer.
1572 let pending_preview = build_pending_input_preview(app);
1573 let desired_preview_height = if mini {
1574 0
1575 } else {
1576 pending_preview.desired_height(shell_area.width)
1577 };
1578
1579 // The background-work chip (#5286) that used to pin a row above the
1580 // composer is gone: the posture bar's live counts own "what is in
1581 // flight" (one owner per fact), and nothing sits between the transcript
1582 // and the composer that is not a queued draft or an expanded panel.
1583
1584 // WorkflowPanel unified activity surface (#4121). Expanded while running
1585 // (interactive drill-in above the composer); when collapsed the panel
1586 // takes no rows — its persistent status lives in the top status bar as a
1587 // header chip instead (#5040). Zero height when no panel.
1588 let desired_workflow_panel_height = if mini {
1589 0
1590 } else {
1591 app.workflow_panel
1592 .as_ref()
1593 .filter(|panel| panel.expanded)
1594 .map(|panel| panel.desired_height(shell_area.width))
1595 .unwrap_or(0)
1596 };
1597 let plugin_cta_height = if mini && !mini_cfg.keep_input {
1598 0
1599 } else {
1600 app.plugin_cta_row_height()
1601 };
1602 let auxiliary_budget = body_height.saturating_sub(
1603 top_work_strip_height
1604 .saturating_add(MIN_CHAT_HEIGHT)
1605 .saturating_add(composer_height)
1606 .saturating_add(footer_height)
1607 .saturating_add(info_height)
1608 .saturating_add(plugin_cta_height),
1609 );
1610 // Queued-only previews author the direct controls in row two (and fall
1611 // back to controls-only when just one row remains). Mixed previews retain
1612 // up to three compact rows at the release floor.
1613 let preview_cap = if size.height >= 20 { 4 } else { 3 };
1614 let preview_height = desired_preview_height.min(auxiliary_budget.min(preview_cap));
1615 let workflow_panel_height =
1616 desired_workflow_panel_height.min(auxiliary_budget.saturating_sub(preview_height));
1617
1618 // Two pinned rows bracket the composer from below (SHELL-DESIGN-20260901
1619 // §2.0 item 3, §2.3b): the posture bar — permission · mode · live counts
1620 // · the one hint that applies now, with the remote-control state or a
1621 // live notice pinned right — then the metrics line — model · ctx · cost
1622 // · ttft · tok/s · ↓ tokens, with the help hint pinned right. Both rows
1623 // are reserved in every phase, so a turn moving between idle, thinking,
1624 // tool use, approval, completion, failure, and cancellation rewrites
1625 // text inside fixed rows — the composer is never displaced.
1626 // The work surface (roster, to-do) lives BELOW those two rows by default
1627 // and only when it has content — scrolling up is intentional history —
1628 // while `top` placement keeps the strip above the transcript. The strip
1629 // owns a slot at each end and only one has height, so every other slot
1630 // keeps its index in both placements (the stage and preview are
1631 // addressed by position below).
1632 // Bottom never falls back (only side rails do), so the configured
1633 // placement is the effective one here.
1634 let strip_below =
1635 app.work_surface.placement == crate::tui::work_surface::WorkSurfacePlacement::Bottom;
1636 let (strip_above_height, strip_below_height) = if strip_below {
1637 (0, top_work_strip_height)
1638 } else {
1639 (top_work_strip_height, 0)
1640 };
1641 let body_chunks = Layout::default()
1642 .direction(Direction::Vertical)
1643 .flex(ratatui::layout::Flex::Start)
1644 .constraints([
1645 Constraint::Length(strip_above_height), // Tasks + To-do above transcript (`top`)
1646 Constraint::Min(1), // Chat area
1647 Constraint::Length(workflow_panel_height), // Workflow panel (#4121)
1648 Constraint::Length(preview_height), // Pending input preview (0 if empty)
1649 Constraint::Length(plugin_cta_height), // Live plugin CTA (0 unless matched)
1650 Constraint::Length(composer_height), // Composer
1651 Constraint::Length(footer_height), // Posture bar
1652 Constraint::Length(info_height), // Metrics line
1653 Constraint::Length(strip_below_height), // Roster + To-do under the chrome (`bottom`)
1654 ])
1655 .split(body_area);
1656 let strip_slot = if strip_below { 8 } else { 0 };
1657 let plugin_cta_slot = 4;
1658 let composer_slot = 5;
1659 let footer_slot = 6;
1660 let info_slot = 7;
1661
1662 if matches!(
1663 app.view_stack.top_kind(),
1664 Some(ModalKind::Approval | ModalKind::UserInput)
1665 ) {
1666 app.viewport.last_prompt_area = app.view_stack.top_occupied_region(size);
1667 }
1668 // Bottom prompts cover part of the ordinary chat slot. Resolve scrolling
1669 // against the rows that remain visible, or End leaves the newest content
1670 // underneath the prompt and PageUp counts rows the user cannot see.
1671 let mut visible_chat_area = body_chunks[1];
1672 if let Some(prompt) = app.viewport.last_prompt_area {
1673 visible_chat_area.height = visible_chat_area
1674 .height
1675 .min(prompt.y.saturating_sub(visible_chat_area.y));
1676 }
1677 let (work_chat_area, side_work_area) = if mini && !mini_cfg.keep_sidebar {
1678 // Mini mode without the side rail: the transcript takes the whole
1679 // chat row. split_chat is skipped so the rail never reserves columns.
1680 (visible_chat_area, None)
1681 } else {
1682 crate::tui::work_surface::split_chat(
1683 app,
1684 visible_chat_area,
1685 rail_min_chat_width(idle_empty),
1686 )
1687 };
1688
1689 if top_work_strip_height > 0 {
1690 crate::tui::work_surface::render(f, body_chunks[strip_slot], app);
1691 } else if let Some(work_area) = side_work_area {
1692 crate::tui::work_surface::render(f, work_area, app);
1693 }
1694
1695 // Render the transcript and optional file-tree sidecar. The underwater
1696 // default deliberately has no legacy right sidebar: Tasks and To-do own
1697 // the strip above, Fleet owns `/fleet`, and dense context owns its
1698 // inspector. Keeping the sidebar here was the architectural reason the
1699 // rejected build still read as the old TUI under a gradient.
1700 let shell_ocean;
1701 {
1702 // Defensive backstop (#400): fill the entire body area with ink
1703 // background before any sub-widgets render, so cells that end up
1704 // uncovered by layout splits (e.g. after file-tree toggle or
1705 // resize) don't retain stale content from a previous frame.
1706 Block::default()
1707 .style(Style::default().bg(app.ui_theme.surface_bg))
1708 .render(work_chat_area, f.buffer_mut());
1709
1710 // When the file-tree pane is visible and the terminal is wide
1711 // enough, reserve the left ~25% for the file tree.
1712 let chat_area =
1713 if app.file_tree.is_some() && work_chat_area.width >= FILE_TREE_MIN_HOST_WIDTH {
1714 app.file_tree_visible = true;
1715 let split = Layout::default()
1716 .direction(Direction::Horizontal)
1717 .constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
1718 .split(work_chat_area);
1719 let tree_area = split[0];
1720 let remaining = split[1];
1721
1722 // Render the file-tree pane.
1723 if let Some(ref mut state) = app.file_tree {
1724 crate::tui::file_tree::render_file_tree(f, tree_area, state, app.ui_theme.mode);
1725 }
1726
1727 remaining
1728 } else {
1729 app.file_tree_visible = false;
1730 work_chat_area
1731 };
1732 app.sidebar_hover_tooltip = None;
1733
1734 if app.agent_focus.is_some() && !app.launch.return_to_session {
1735 // A focused worker's full transcript owns the conversation area;
1736 // the ocean column and every other shell surface stay as they are.
1737 //
1738 // The widget below is built only to sample the ocean column, but
1739 // its constructor also consumes `pending_scroll_delta` into the
1740 // (invisible) main-transcript scroll state — which would starve
1741 // the focused transcript of every PageUp/PageDown and wheel
1742 // event. Park the delta across the sample so `render_focus`
1743 // receives it and the focused pane scrolls exactly like the main
1744 // transcript.
1745 let parked_scroll_delta = app.viewport.pending_scroll_delta;
1746 app.viewport.pending_scroll_delta = 0;
1747 {
1748 let chat_widget = ChatWidget::new(app, chat_area).with_ocean_viewport(size);
1749 shell_ocean = chat_widget.ocean_column();
1750 }
1751 app.viewport.pending_scroll_delta = parked_scroll_delta;
1752 crate::tui::agent_focus::refresh_focus(app);
1753 let buf = f.buffer_mut();
1754 crate::tui::agent_focus::render_focus(app, chat_area, buf);
1755 } else {
1756 if app.launch.visible
1757 && !app.launch.return_to_session
1758 && app.onboarding == crate::tui::app::OnboardingState::None
1759 {
1760 app.launch
1761 .mark_reveal_started_at
1762 .get_or_insert_with(std::time::Instant::now);
1763 }
1764 let chat_widget = ChatWidget::new(app, chat_area).with_ocean_viewport(size);
1765 shell_ocean = chat_widget.ocean_column();
1766 let buf = f.buffer_mut();
1767 chat_widget.render(chat_area, buf);
1768 }
1769 // The launch card's rows are clickable where they painted. The row
1770 // offsets come from the same builder that produced the lines, so a
1771 // hitbox cannot describe a row the transcript did not draw — and a
1772 // fully dissolved card painted nothing this frame, so it owns no
1773 // rows either.
1774 if app.launch.card_paintable(
1775 app.ambient_clock_ms,
1776 app.motion_policy().allows_decorative(),
1777 ) {
1778 crate::tui::underwater::refresh_launch_row_hitboxes(app, chat_area);
1779 } else if !app.launch.row_hitboxes.is_empty() {
1780 app.launch.row_hitboxes.clear();
1781 }
1782 }
1783
1784 // Workflow panel between chat and pending-input preview (#4121).
1785 if workflow_panel_height > 0 {
1786 if let Some(panel) = app.workflow_panel.as_ref() {
1787 let area = body_chunks[2];
1788 app.viewport.last_workflow_panel_area = Some(area);
1789 app.viewport.last_workflow_cancel_area =
1790 panel.cancel_hint_span(area.width).map(|(start, end)| Rect {
1791 x: area.x.saturating_add(start),
1792 y: area.y,
1793 width: end.saturating_sub(start),
1794 height: 1,
1795 });
1796 let buf = f.buffer_mut();
1797 panel.render(area, buf);
1798 }
1799 } else {
1800 app.viewport.last_workflow_panel_area = None;
1801 app.viewport.last_workflow_cancel_area = None;
1802 }
1803
1804 // Render pending-input preview (queued/steered messages, if any).
1805 if preview_height > 0 {
1806 let buf = f.buffer_mut();
1807 pending_preview.render(body_chunks[3], buf);
1808 }
1809
1810 if plugin_cta_height > 0 {
1811 let buf = f.buffer_mut();
1812 crate::tui::plugin_suggestions::draw_plugin_cta(app, body_chunks[plugin_cta_slot], buf);
1813 } else {
1814 app.viewport.last_plugin_cta_area = None;
1815 app.viewport.last_plugin_cta_review_area = None;
1816 app.viewport.last_plugin_cta_dismiss_area = None;
1817 }
1818
1819 // Render composer
1820 let cursor_pos = {
1821 let composer_widget = ComposerWidget::new(
1822 app,
1823 composer_max_height,
1824 &slash_menu_entries,
1825 &mention_menu_entries,
1826 );
1827 let buf = f.buffer_mut();
1828 composer_widget.render(body_chunks[composer_slot], buf);
1829 composer_widget.cursor_pos(body_chunks[composer_slot])
1830 };
1831 app.viewport.last_composer_area = Some(body_chunks[composer_slot]);
1832 {
1833 let area = body_chunks[composer_slot];
1834 let composer_widget = ComposerWidget::new(
1835 app,
1836 composer_max_height,
1837 &slash_menu_entries,
1838 &mention_menu_entries,
1839 );
1840 let input_plane = composer_widget.inner_area(area);
1841 app.viewport.last_composer_content = Some(input_plane);
1842
1843 // Compute scroll offset and top padding for mouse coordinate mapping.
1844 let input_text = app.composer_display_input();
1845 let input_cursor = app.composer_display_cursor();
1846 let content_geometry = crate::tui::widgets::composer_content_geometry(
1847 input_plane,
1848 app.is_history_search_active(),
1849 );
1850 let content_width = content_geometry.text_width();
1851 let menu_lines = ComposerWidget::new(
1852 app,
1853 composer_max_height,
1854 &slash_menu_entries,
1855 &mention_menu_entries,
1856 )
1857 .active_menu_reserved_rows();
1858 let budget =
1859 crate::tui::widgets::composer_input_rows_budget(input_plane.height, menu_lines);
1860 let (_, _, _, scroll_offset) = crate::tui::widgets::layout_input_with_scroll(
1861 input_text,
1862 input_cursor,
1863 content_width,
1864 budget,
1865 );
1866 let visual_rows = if input_text.is_empty() {
1867 let hint: Option<std::borrow::Cow<'_, str>> = if let Some(ref suggestion) =
1868 app.prompt_suggestion
1869 && !app.is_history_search_active()
1870 {
1871 Some(std::borrow::Cow::Borrowed(suggestion.as_str()))
1872 } else {
1873 Some(crate::tui::widgets::composer_empty_hint_text(app))
1874 };
1875 crate::tui::widgets::empty_composer_visual_rows(hint.as_deref(), content_width, budget)
1876 } else {
1877 // Count wrapped lines (approximation matching the render path).
1878 crate::tui::widgets::wrap_input_lines_for_mouse(input_text, content_width).len()
1879 };
1880 let top_padding = budget.saturating_sub(visual_rows.clamp(1, budget));
1881 app.viewport.last_composer_scroll_offset = scroll_offset;
1882 app.viewport.last_composer_top_padding = top_padding;
1883 }
1884 // The posture bar is the first row under the composer: permission chip
1885 // (never sheds), mode, live counts, the one hint that applies now, with
1886 // the remote-control state or a live notice pinned right.
1887 if footer_height > 0 {
1888 let area = body_chunks[footer_slot];
1889 let facts = crate::tui::phase_strip::tideline_footer_from_app(app, area.width);
1890 let footer = facts
1891 .widget(
1892 &app.ui_theme,
1893 crate::tui::color_compat::ascii_safe_enabled(),
1894 )
1895 .compact(app.posture_bar == crate::config::ChromeRowPreset::Compact);
1896 let buf = f.buffer_mut();
1897 Block::default()
1898 .style(Style::default().bg(app.ui_theme.footer_bg))
1899 .render(area, buf);
1900 let count_rects = crate::tui::phase_strip::render_tideline_footer(area, buf, &footer);
1901 register_footer_count_targets(app, &facts, &count_rects);
1902 }
1903
1904 // The metrics line sits directly under the posture bar: model · ctx ·
1905 // cost · ttft · tok/s · ↓ tokens, with the help hint pinned right.
1906 let mut info_interactions = InfoLineInteractionHitboxes::default();
1907 if info_height > 0 {
1908 info_interactions = render_info_row(f, app, body_chunks[info_slot], idle_empty);
1909 } else {
1910 app.viewport.last_infoline_hitboxes.clear();
1911 }
1912 register_info_interaction_targets(app, info_interactions);
1913
1914 // The underwater shell is one water column, not a stack of independently
1915 // shaded panels. Continue the transcript's absolute-row ramp through each
1916 // ordinary shell surface after its foreground has rendered. Semantic
1917 // backgrounds such as selection, hover, errors, and code blocks do not
1918 // match these base colors and therefore remain intact.
1919 if let Some(column) = shell_ocean {
1920 // The working canvas may keep a small responsive gutter, but the water
1921 // does not stop at that content edge. Paint the cleared terminal floor
1922 // first so wide layouts read as one ocean rather than a blue card
1923 // floating between black banks. `paint_matching` leaves every semantic
1924 // widget background untouched.
1925 column.paint_matching(size, f.buffer_mut(), app.ui_theme.surface_bg);
1926 if top_work_strip_height > 0 {
1927 column.paint_matching(
1928 body_chunks[strip_slot],
1929 f.buffer_mut(),
1930 app.ui_theme.surface_bg,
1931 );
1932 }
1933 if let Some(side_area) = side_work_area {
1934 column.paint_matching(side_area, f.buffer_mut(), app.ui_theme.surface_bg);
1935 }
1936 column.paint_matching(work_chat_area, f.buffer_mut(), app.ui_theme.surface_bg);
1937 column.paint_matching(body_chunks[2], f.buffer_mut(), app.ui_theme.surface_bg);
1938 column.paint_matching(body_chunks[3], f.buffer_mut(), app.ui_theme.surface_bg);
1939 if plugin_cta_height > 0 {
1940 column.paint_matching(
1941 body_chunks[plugin_cta_slot],
1942 f.buffer_mut(),
1943 app.ui_theme.composer_bg,
1944 );
1945 }
1946 column.paint_matching(
1947 body_chunks[composer_slot],
1948 f.buffer_mut(),
1949 app.ui_theme.composer_bg,
1950 );
1951 if footer_height > 0 {
1952 column.paint_matching(
1953 body_chunks[footer_slot],
1954 f.buffer_mut(),
1955 app.ui_theme.footer_bg,
1956 );
1957 }
1958 }
1959 register_clickable_chrome_for_hover(app);
1960 crate::tui::hover_layer::apply_resolved_effects(
1961 f.buffer_mut(),
1962 app.effective_low_motion_for_status(),
1963 &app.ui_theme,
1964 );
1965 if !app.view_stack.is_empty() {
1966 // The live transcript overlay snapshots the app's history + active
1967 // cell on each render so streaming mutations propagate. Other views
1968 // are static and skip this refresh.
1969 if app.view_stack.top_kind() == Some(ModalKind::LiveTranscript) {
1970 refresh_live_transcript_overlay(app);
1971 } else if app.view_stack.top_kind() == Some(ModalKind::ContextInspector) {
1972 refresh_context_inspector_overlay(app);
1973 }
1974 let buf = f.buffer_mut();
1975 app.view_stack.render(size, buf);
1976 }
1977
1978 cursor_pos
1979 }
1980
1981 /// Hide the real terminal caret before ratatui applies a frame diff.
1982 ///
1983 /// A diff moves the terminal cursor through every changed run. Electron/xterm
1984 /// IME bridges (notably Tabby on Windows, #5023) can observe those transient
1985 /// positions even though the final frame is correct, which makes the native
1986 /// candidate window jump around the screen. Keep the caret hidden for the
1987 /// whole diff and pair this with [`finish_frame_cursor`] after the draw.
1988 pub(super) fn prepare_frame_cursor<B: ratatui::backend::Backend>(
1989 terminal: &mut Terminal<B>,
1990 ) -> std::result::Result<(), B::Error> {
1991 terminal.hide_cursor()
1992 }
1993
1994 /// Restore the composer caret in IME-safe order: position first, reveal last.
1995 ///
1996 /// Ratatui's `Frame::set_cursor_position` path currently calls `show_cursor`
1997 /// before `set_cursor_position`. That briefly exposes the stale or last-diff
1998 /// position to the terminal's IME bridge. Owning the final two operations here
1999 /// preserves ratatui's internal cursor tracking while ensuring there is only
2000 /// one visible caret position per completed frame (#5023).
2001 pub(super) fn finish_frame_cursor<B: ratatui::backend::Backend>(
2002 terminal: &mut Terminal<B>,
2003 cursor_pos: Option<(u16, u16)>,
2004 ) -> std::result::Result<(), B::Error> {
2005 if let Some(cursor_pos) = cursor_pos {
2006 terminal.set_cursor_position(cursor_pos)?;
2007 terminal.show_cursor()?;
2008 }
2009 Ok(())
2010 }
2011
2012 /// Draw a complete application frame, optionally with a full viewport reset.
2013 ///
2014 /// When `full_repaint` is true, the terminal scroll margins and origin mode
2015 /// are reset, the screen is cleared, ratatui's buffer is emptied, and then
2016 /// the full UI is drawn — all within a single DEC 2026 synchronized-update
2017 /// batch so GPU-accelerated terminals (Ghostty, VS Code, Kitty) render one
2018 /// complete frame instead of a blank intermediate frame followed by the UI.
2019 ///
2020 /// When `full_repaint` is false, only the diff from the previous draw is
2021 /// written (normal incremental update path).
2022 pub(crate) fn draw_app_frame_inner(
2023 terminal: &mut AppTerminal,
2024 app: &mut App,
2025 config: &Config,
2026 full_repaint: bool,
2027 ) -> Result<()> {
2028 terminal.backend_mut().set_palette_mode(app.ui_theme.mode);
2029 terminal.backend_mut().set_theme(app.theme_id, app.ui_theme);
2030 // DEC 2026 wrapping is on by default but can be turned off for
2031 // terminals that mishandle it (Ptyxis 50.x + VTE 0.84.x flashes the
2032 // whole viewport on every wrapped frame instead of deferring as the
2033 // standard requires). Settings::synchronized_output_enabled resolves
2034 // the user's setting against the Ptyxis env auto-detect.
2035 let wrap_in_sync_update = app.synchronized_output_enabled;
2036 if wrap_in_sync_update {
2037 let _ = terminal.backend_mut().write_all(BEGIN_SYNC_UPDATE);
2038 }
2039
2040 // Run fallible draw operations in a closure so END_SYNC_UPDATE is
2041 // always sent even if an intermediate step fails. Without this, a
2042 // failing `?` would return early and leave the terminal stuck in
2043 // synchronized-update mode (screen frozen).
2044 let result = (|| -> Result<()> {
2045 // The terminal cursor itself is also input-method geometry. Hide it
2046 // before clear/diff operations move it, then restore the one composer
2047 // position after ratatui finishes drawing (#5023).
2048 prepare_frame_cursor(terminal)?;
2049 if full_repaint {
2050 terminal.backend_mut().write_all(TERMINAL_ORIGIN_RESET)?;
2051 terminal.clear()?;
2052 }
2053 let mut cursor_pos = None;
2054 terminal.draw(|f| cursor_pos = render(f, app, config))?;
2055 app.pet_watch.present(terminal.backend_mut())?;
2056 finish_frame_cursor(terminal, cursor_pos)?;
2057 Ok(())
2058 })();
2059
2060 // Always end the synchronized update, regardless of success or failure.
2061 if wrap_in_sync_update {
2062 let _ = terminal.backend_mut().write_all(END_SYNC_UPDATE);
2063 }
2064 let _ = terminal.backend_mut().flush();
2065 result
2066 }
2067
2068 /// Count how many `HistoryCell::User` entries currently live in the
2069 /// transcript. Used by the backtrack state machine to decide whether
2070 /// there's anything to rewind to. Walks `app.history` directly so it
2071 /// stays accurate even mid-stream (the streaming Assistant cell never
2072 /// counts as a user turn).
2073 pub(crate) fn count_user_history_cells(app: &App) -> usize {
2074 app.history
2075 .iter()
2076 .filter(|cell| matches!(cell, HistoryCell::User { .. }))
2077 .count()
2078 }
2079
2080 /// Find the absolute index of the Nth-from-tail `HistoryCell::User` in
2081 /// `app.history`. `depth` of 0 selects the most recent user cell.
2082 /// Returns `None` if `depth` is out of range.
2083 pub(crate) fn find_user_cell_index_from_tail(app: &App, depth: usize) -> Option<usize> {
2084 let mut count = 0usize;
2085 for (idx, cell) in app.history.iter().enumerate().rev() {
2086 if matches!(cell, HistoryCell::User { .. }) {
2087 if count == depth {
2088 return Some(idx);
2089 }
2090 count += 1;
2091 }
2092 }
2093 None
2094 }
2095
2096 /// Truncate `text` to at most `max_chars` characters, cutting at the last
2097 /// natural phrase boundary (`.`, `,`, `:`, `;`, `—`, `-`, or whitespace)
2098 /// so words are never split. Appends `…` only when text was actually cut.
2099 pub(crate) fn short_title_truncate(text: &str, max_chars: usize) -> String {
2100 if text.chars().count() <= max_chars {
2101 return text.to_string();
2102 }
2103 // Find the boundary as a character index. `str::rfind` returns a byte
2104 // offset, which mis-counts multi-byte UTF-8 text when fed back into
2105 // `chars().take()`, so operate on `Vec<char>` instead.
2106 let candidate: Vec<char> = text.chars().take(max_chars).collect();
2107 let boundary = candidate
2108 .iter()
2109 .rposition(|&c| matches!(c, '.' | ',' | ':' | ';' | '—' | '-'))
2110 .or_else(|| candidate.iter().rposition(|&c| c == ' '))
2111 .unwrap_or(max_chars.min(candidate.len()).saturating_sub(1));
2112 let cut: String = text.chars().take(boundary.max(1)).collect();
2113 format!("{cut}…")
2114 }
2115
2116 pub(crate) fn compact_user_context_display(content: &str) -> String {
2117 content
2118 .split("\n\n---\n\nLocal context from @mentions:")
2119 .next()
2120 .unwrap_or(content)
2121 .to_string()
2122 }
2123
2124 #[cfg(test)]
2125 pub(crate) fn transcript_scroll_percent(top: usize, visible: usize, total: usize) -> Option<u16> {
2126 if total <= visible {
2127 return None;
2128 }
2129
2130 let max_top = total.saturating_sub(visible);
2131 if max_top == 0 {
2132 return None;
2133 }
2134
2135 let clamped_top = top.min(max_top);
2136 let percent = ((clamped_top as f64 / max_top as f64) * 100.0).round() as u16;
2137 Some(percent.min(100))
2138 }
2139
2140 pub(crate) fn estimated_context_tokens(app: &App) -> Option<i64> {
2141 // ONE estimator: this is `compaction::estimate_input_tokens_for_pressure`
2142 // over the same message list (per-message cache, framing included) —
2143 // deliberately not the 1.5x conservative variant. The meter, the >=80%
2144 // depth warning, and the auto-compact gate must agree about where the
2145 // threshold is: the inflated estimate used to show "ctx 82%" while the
2146 // gate read ~55% and correctly refused to compact (#6297). The 1.5x
2147 // inflation stays where it belongs — request-overflow protection
2148 // (`estimate_input_tokens_conservative`).
2149 let message_count = app.api_messages.len();
2150 let mut cache = app.context_token_cache.borrow_mut();
2151 if cache.message_tokens.len() > message_count {
2152 cache.message_tokens.truncate(message_count);
2153 }
2154 while cache.message_tokens.len() < message_count {
2155 let index = cache.message_tokens.len();
2156 cache
2157 .message_tokens
2158 .push(estimate_tokens(&app.api_messages[index..=index]));
2159 }
2160 // The final assistant/tool message may grow while streaming. Recompute
2161 // only that tail entry; historical messages remain O(1) on steady frames.
2162 if message_count > 0 {
2163 let last = message_count - 1;
2164 cache.message_tokens[last] = estimate_tokens(&app.api_messages[last..=last]);
2165 }
2166 let message_tokens = cache.message_tokens.iter().copied().sum::<usize>();
2167 let system_tokens =
2168 estimate_input_tokens_conservative(&[], app.system_prompt.as_ref()).saturating_sub(48);
2169 let estimated = message_tokens
2170 .saturating_add(system_tokens)
2171 .saturating_add(message_count.saturating_mul(12))
2172 .saturating_add(48);
2173 i64::try_from(estimated).ok()
2174 }
2175
2176 pub(crate) fn context_usage_snapshot(app: &App) -> Option<(i64, u32, f64)> {
2177 let max = crate::route_budget::route_context_window_tokens(
2178 app.api_provider,
2179 app.effective_model_for_budget(),
2180 app.active_route_limits,
2181 );
2182 context_usage_snapshot_for_window(app, max)
2183 }
2184
2185 pub(crate) fn context_usage_snapshot_for_window(app: &App, max: u32) -> Option<(i64, u32, f64)> {
2186 let max_i64 = i64::from(max);
2187 let reported = app
2188 .session
2189 .last_prompt_tokens
2190 .map(i64::from)
2191 .map(|tokens| tokens.max(0));
2192 let estimated = estimated_context_tokens(app).map(|tokens| tokens.max(0));
2193
2194 // Always prefer the estimated current-context size (computed from
2195 // `app.api_messages`) when we have it. Reported `last_prompt_tokens`
2196 // comes from `Event::TurnComplete.usage`, which the engine builds with
2197 // `turn.add_usage` — that SUMS input_tokens across every round in the
2198 // turn, so a multi-round tool-call turn reports a value much larger
2199 // than the actual context window state, then the next single-round
2200 // turn drops back to a single round's input_tokens. User-visible %
2201 // was bouncing 31% → 9% (#115) because of this. The estimate is
2202 // monotonic wrt conversation growth, which is what a "context filling
2203 // up" indicator should show. We still consult `reported` only as a
2204 // fallback when no estimate is available (e.g., immediately after a
2205 // session restore before the api_messages are populated).
2206 let used = match (estimated, reported) {
2207 (Some(estimated), _) => estimated.min(max_i64),
2208 (None, Some(reported)) => reported.min(max_i64),
2209 (None, None) => return None,
2210 };
2211
2212 let max_f64 = f64::from(max);
2213 let used_f64 = used as f64;
2214 let percent = ((used_f64 / max_f64) * 100.0).clamp(0.0, 100.0);
2215 Some((used, max, percent))
2216 }
2217
2218 /// True while a `workflow` tool is executing in the foreground (active cell)
2219 /// or still shown as running in history. Used to keep per-subagent completion
2220 /// notifications quiet during a workflow run under `final-only`.
2221 pub(crate) fn workflow_tool_is_running(app: &App) -> bool {
2222 fn is_running_workflow(cell: &HistoryCell) -> bool {
2223 matches!(
2224 cell,
2225 HistoryCell::Tool(ToolCell::Generic(tool))
2226 if tool.name == "workflow" && tool.status == ToolStatus::Running
2227 )
2228 }
2229 app.history.iter().any(is_running_workflow)
2230 || app
2231 .active_cell
2232 .as_ref()
2233 .is_some_and(|active| active.entries().iter().any(is_running_workflow))
2234 }
2235
2236 #[cfg(test)]
2237 mod tests {
2238 use super::{register_info_interaction_targets, render_info_row, short_title_truncate};
2239 use ratatui::{Terminal, backend::TestBackend};
2240
2241 /// Chrome that answers a click must also answer the pointer, or the app
2242 /// teaches people that pointing at things does not work here.
2243 #[test]
2244 fn clickable_chrome_registers_a_hover_target() {
2245 let _guard = crate::tui::hover_layer::HOVER_TEST_LOCK.lock().unwrap();
2246 crate::tui::hover_layer::begin_frame();
2247 let mut app =
2248 crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
2249 let button = ratatui::layout::Rect::new(70, 10, 3, 3);
2250 app.viewport.jump_to_latest_button_area = Some(button);
2251
2252 super::register_clickable_chrome_for_hover(&app);
2253
2254 let registered = crate::tui::hover_layer::registered_targets();
2255 assert!(
2256 registered.iter().any(|hit| hit.area == button),
2257 "the jump-to-latest button handles a click in mouse_ui and must \
2258 light up under the pointer; registered: {registered:?}"
2259 );
2260 }
2261
2262 /// The composer's `[↵]` answered clicks and showed nothing under the
2263 /// pointer — the last of the clickable-but-dark controls. It lights up
2264 /// only when a click there would actually send.
2265 #[test]
2266 fn composer_send_target_lights_up_only_when_it_would_send() {
2267 let _guard = crate::tui::hover_layer::HOVER_TEST_LOCK.lock().unwrap();
2268 let mut app =
2269 crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
2270 app.launch.visible = false;
2271 app.composer_border = true;
2272 let area = ratatui::layout::Rect::new(0, 20, 80, 4);
2273 app.viewport.last_composer_area = Some(area);
2274 app.viewport.last_composer_content = Some(ratatui::layout::Rect::new(1, 21, 73, 2));
2275 let submit = crate::tui::widgets::active_composer_submit_rect(&app, area)
2276 .expect("enclosed composer submit");
2277
2278 // Empty draft: the click path refuses, so the pointer must not promise.
2279 app.input.clear();
2280 app.cursor_position = 0;
2281 crate::tui::hover_layer::begin_frame();
2282 super::register_clickable_chrome_for_hover(&app);
2283 assert!(
2284 !crate::tui::hover_layer::registered_targets()
2285 .iter()
2286 .any(|hit| hit.area == submit),
2287 "an inert send target must not advertise itself"
2288 );
2289
2290 app.input = "ship it".to_string();
2291 app.cursor_position = app.input.chars().count();
2292 crate::tui::hover_layer::begin_frame();
2293 super::register_clickable_chrome_for_hover(&app);
2294 assert!(
2295 crate::tui::hover_layer::registered_targets()
2296 .iter()
2297 .any(|hit| hit.area == submit),
2298 "a live send target must light up under the pointer"
2299 );
2300 }
2301
2302 #[test]
2303 fn infoline_route_segment_registers_interaction_target() {
2304 let mut app =
2305 crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
2306 let mut terminal =
2307 Terminal::new(TestBackend::new(160, 1)).expect("info-line test terminal should build");
2308
2309 terminal
2310 .draw(|frame| {
2311 let area = frame.area();
2312 let hitboxes = render_info_row(frame, &mut app, area, false);
2313 register_info_interaction_targets(&mut app, hitboxes);
2314 })
2315 .expect("info line should render");
2316
2317 let segment = app
2318 .viewport
2319 .last_infoline_hitboxes
2320 .iter()
2321 .find(|hitbox| hitbox.id == crate::tui::infoline::InfoSegmentId::Model)
2322 .expect("a wide info line should paint its model segment");
2323 let target_for = |id| {
2324 app.viewport
2325 .interaction_targets
2326 .iter()
2327 .find(|target| target.id == id)
2328 .cloned()
2329 .unwrap_or_else(|| panic!("painted route segment should register {id:?}"))
2330 };
2331 // The segment reads `provider · model · effort`. Pointing at the
2332 // provider is a different request from pointing at the model, so the
2333 // one target became two: the whole span used to open `/provider` no
2334 // matter which name was under the pointer.
2335 let provider = target_for(crate::tui::tideline::InteractionTargetId::HEADER_ROUTE);
2336 let model = target_for(crate::tui::tideline::InteractionTargetId::HEADER_MODEL);
2337
2338 assert_eq!(provider.area.x, segment.area.x);
2339 assert!(
2340 provider.area.right() < model.area.x,
2341 "provider {:?} and model {:?} must not overlap",
2342 provider.area,
2343 model.area
2344 );
2345 assert_eq!(model.area.right(), segment.area.right());
2346 assert_eq!(
2347 provider.keyboard_action,
2348 Some(crate::tui::tideline::InteractionAction::OpenProviderPicker)
2349 );
2350 assert_eq!(
2351 model.keyboard_action,
2352 Some(crate::tui::tideline::InteractionAction::OpenModelPicker)
2353 );
2354 for target in [&provider, &model] {
2355 assert_eq!(target.mouse_action, target.keyboard_action);
2356 assert_eq!(
2357 target.inspect_detail,
2358 crate::tui::tideline::InspectDetail::Route
2359 );
2360 }
2361 }
2362
2363 /// "Where did the github info go?" — the workspace segment names the
2364 /// repository when `origin` resolves to a forge slug, and only falls back
2365 /// to the folder basename when it does not. The basename rides along as
2366 /// the segment's shorter form so a long slug never costs the row a whole
2367 /// fact.
2368 #[test]
2369 fn truncates_at_ascii_word_boundary() {
2370 assert_eq!(short_title_truncate("hello world foo", 10), "hello…");
2371 }
2372
2373 #[test]
2374 fn truncates_non_ascii_titles_by_char_count_not_bytes() {
2375 // `str::rfind` returns a byte offset; using it as a char count used to
2376 // cut past the limit and mid-word on multi-byte input.
2377 assert_eq!(
2378 short_title_truncate("你好 world and more", 10),
2379 "你好 world…"
2380 );
2381 }
2382
2383 #[test]
2384 fn truncates_at_punctuation_boundary() {
2385 assert_eq!(short_title_truncate("hello, world", 8), "hello…");
2386 }
2387
2388 #[test]
2389 fn truncates_mid_word_when_no_boundary_exists() {
2390 assert_eq!(short_title_truncate("abcdefghij", 5), "abcd…");
2391 }
2392
2393 #[test]
2394 fn leaves_short_titles_untouched() {
2395 assert_eq!(short_title_truncate("short", 10), "short");
2396 }
2397
2398 // ── #5950: the bottom chrome is the user's to compose ─────────────
2399
2400 use crate::config::StatusItem;
2401 use crate::tui::app::App;
2402 use crate::tui::infoline::{InfoLine, InfoSegmentId};
2403
2404 /// A session whose context is `pct` full, by pinning the route's window
2405 /// to a multiple of what this conversation actually estimates. Nothing
2406 /// here fakes the reading itself — it goes through
2407 /// `context_usage_snapshot` like the live shell does.
2408 fn app_with_context_percent(pct: u8) -> App {
2409 use codewhale_models::{ContentBlock, Message};
2410 let mut app =
2411 crate::test_support::test_app_with_options(crate::test_support::test_tui_options("."));
2412 app.api_messages = std::sync::Arc::new(vec![Message {
2413 role: codewhale_models::Role::User,
2414 content: vec![ContentBlock::Text {
2415 text: "context ".repeat(400),
2416 cache_control: None,
2417 }],
2418 }]);
2419 let (used, _, _) =
2420 super::context_usage_snapshot(&app).expect("a conversation has a context reading");
2421 let window = (used as f64 * 100.0 / f64::from(pct)).round().max(1.0);
2422 app.active_route_limits = Some(codewhale_config::route::RouteLimits {
2423 context_tokens: Some(window as u64),
2424 ..Default::default()
2425 });
2426 assert_eq!(
2427 super::info_context_percent(&app),
2428 pct,
2429 "fixture should land exactly on {pct}%"
2430 );
2431 app
2432 }
2433
2434 /// The metrics line as the user reads it, at `width`.
2435 fn metrics_row(app: &App, width: u16) -> String {
2436 let segments = super::info_segments(app, width);
2437 let hint = crate::tui::shell_key_routing::info_help_hint(app.ui_locale);
2438 let backend = TestBackend::new(width, 1);
2439 let mut terminal = Terminal::new(backend).expect("metrics-line terminal");
2440 terminal
2441 .draw(|frame| {
2442 use ratatui::widgets::Widget as _;
2443 let area = frame.area();
2444 InfoLine::new(&app.ui_theme, &hint, &segments).render(area, frame.buffer_mut());
2445 })
2446 .expect("draw");
2447 terminal
2448 .backend()
2449 .buffer()
2450 .content()
2451 .iter()
2452 .map(|cell| cell.symbol().to_string())
2453 .collect::<String>()
2454 }
2455
2456 /// The reading used to go silent below 50% fullness, which is most of a
2457 /// session (#5950). It is a reading, not an alarm: it states 10% as
2458 /// readily as 60%, and only the ink changes at the thresholds.
2459 #[test]
2460 fn context_reading_paints_at_every_fullness() {
2461 for pct in [10u8, 60] {
2462 let app = app_with_context_percent(pct);
2463 let segment = super::info_segments(&app, 160)
2464 .into_iter()
2465 .find(|segment| segment.id == InfoSegmentId::Context)
2466 .unwrap_or_else(|| panic!("{pct}%: the context reading must be on the row"));
2467 assert_eq!(segment.value, format!("{pct}%"));
2468 assert_eq!(
2469 segment.ink,
2470 codewhale_palette::ChromeInk::Info,
2471 "{pct}%: below the cap the reading is a status, not a failure"
2472 );
2473 // Narrow rows keep it too: the reading is the row's floor and
2474 // sheds after everything else, including the help hint.
2475 for width in [40u16, 80, 160] {
2476 let row = metrics_row(&app, width);
2477 assert!(
2478 row.contains(&format!("ctx {pct}%")),
2479 "{pct}% at {width} columns: {row:?}"
2480 );
2481 }
2482 }
2483 }
2484
2485 /// The warning ink still belongs to the thresholds it always used: the
2486 /// error token from 80% up, and not one percent earlier.
2487 #[test]
2488 fn context_reading_keeps_its_warning_threshold() {
2489 for (pct, expected) in [
2490 (10u8, codewhale_palette::ChromeInk::Info),
2491 (79, codewhale_palette::ChromeInk::Info),
2492 (80, codewhale_palette::ChromeInk::Attention),
2493 ] {
2494 let app = app_with_context_percent(pct);
2495 let segment = super::info_segments(&app, 160)
2496 .into_iter()
2497 .find(|segment| segment.id == InfoSegmentId::Context)
2498 .expect("context reading");
2499 assert_eq!(segment.ink, expected, "{pct}%");
2500 }
2501 }
2502
2503 /// `/statusline` drives this row. Between 0.9.12 and #5950 the picker
2504 /// persisted a list nothing read, so every toggle in it was a lie.
2505 #[test]
2506 fn statusline_toggle_removes_its_segment_on_the_next_frame() {
2507 use crate::tui::views::{ModalView, ViewAction, ViewEvent};
2508 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2509
2510 let mut app = app_with_context_percent(10);
2511 assert!(
2512 metrics_row(&app, 160).contains("ctx 10%"),
2513 "the reading starts on the row"
2514 );
2515
2516 let mut picker = crate::tui::views::status_picker::StatusPickerView::new(
2517 &app.status_items,
2518 app.api_provider,
2519 app.ui_locale,
2520 );
2521 // Walk to the context row the way a user does, then uncheck it.
2522 let context_row = StatusItem::all()
2523 .iter()
2524 .filter(|item| item.is_available_for(app.api_provider))
2525 .position(|item| *item == StatusItem::ContextPercent)
2526 .expect("the picker offers the context reading");
2527 for _ in 0..context_row {
2528 picker.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
2529 }
2530 let action = picker.handle_key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
2531 let ViewAction::Emit(ViewEvent::StatusItemsUpdated { items, .. }) = action else {
2532 panic!("space should emit a live preview: {action:?}");
2533 };
2534 assert!(!items.contains(&StatusItem::ContextPercent));
2535
2536 // What the handler does with the event, and then the next frame.
2537 app.status_items = items;
2538 let row = metrics_row(&app, 160);
2539 assert!(
2540 !row.contains("ctx "),
2541 "the toggle must take it off: {row:?}"
2542 );
2543 assert!(
2544 row.contains("deepseek"),
2545 "and must take nothing else with it: {row:?}"
2546 );
2547 }
2548
2549 /// A custom OpenAI-compatible route without an endpoint receipt cannot
2550 /// prove its effective tier. The route segment used to print
2551 /// `high→effective unavailable` — a placeholder that could never resolve
2552 /// (#5950). It now states no effort field at all, while a first-party
2553 /// route keeps its tier label.
2554 #[test]
2555 fn unprovable_effort_states_no_field_instead_of_a_placeholder() {
2556 use crate::tui::phase_strip::{RouteFieldKind, route_identity_fields};
2557 use crate::tui::underwater::ShellTier;
2558
2559 let mut app = app_with_context_percent(10);
2560 app.set_provider_identity(crate::config::ApiProvider::Custom, "my-gateway");
2561 app.auto_model = false;
2562 app.active_route_base_url = "https://gateway.example/v1".to_string();
2563 app.model = "vendor-model-x".to_string();
2564 app.reasoning_effort = crate::reasoning_preference::ReasoningEffort::High;
2565 assert_eq!(
2566 app.reasoning_effort_display_label(),
2567 "high→effective unavailable",
2568 "the full label still tells /status the truth"
2569 );
2570 assert_eq!(app.provable_reasoning_effort_label(), None);
2571 let fields = route_identity_fields(&app, ShellTier::Wide, 200).expect("route fields");
2572 assert!(
2573 fields
2574 .iter()
2575 .all(|field| field.kind != RouteFieldKind::Effort),
2576 "no effort field on an unprovable route: {fields:?}"
2577 );
2578 let row = metrics_row(&app, 200);
2579 assert!(row.contains("vendor-model-x"), "{row:?}");
2580 // The unresolvable effort placeholder stays out; the localized
2581 // missing-cost explanation ("rate unavailable") is a separate,
2582 // legitimate reading.
2583 assert!(!row.contains("high→effective unavailable"), "{row:?}");
2584 assert!(!row.contains("high"), "{row:?}");
2585
2586 // First-party routes are unchanged: the tier label stays.
2587 let app = app_with_context_percent(10);
2588 let label = app
2589 .provable_reasoning_effort_label()
2590 .expect("a first-party route proves its tier");
2591 assert_eq!(label, app.reasoning_effort_display_label());
2592 let fields = route_identity_fields(&app, ShellTier::Wide, 200).expect("route fields");
2593 assert!(
2594 fields
2595 .iter()
2596 .any(|field| field.kind == RouteFieldKind::Effort && field.text == label),
2597 "{fields:?}"
2598 );
2599 }
2600
2601 /// DeepSeek's clock-tiered routes show which tier the next turn buys,
2602 /// beside the cost; flat routes and other vendors show nothing.
2603 #[test]
2604 fn deepseek_tiered_routes_paint_the_billing_tier_beside_the_cost() {
2605 use crate::config::ApiProvider;
2606 use chrono::TimeZone as _;
2607 let mut app = app_with_context_percent(10);
2608 app.auto_model = false;
2609 app.api_provider = ApiProvider::Deepseek;
2610 app.model = "deepseek-v4-flash".to_string();
2611 // Wednesday 2026-09-16: 02:00Z is inside the 01:00-04:00 peak
2612 // window, 12:00Z outside every window.
2613 let peak = chrono::Utc.with_ymd_and_hms(2026, 9, 16, 2, 0, 0).unwrap();
2614 let off = chrono::Utc.with_ymd_and_hms(2026, 9, 16, 12, 0, 0).unwrap();
2615 assert_eq!(
2616 super::billing_tier_label(&app, peak).as_deref(),
2617 Some("peak")
2618 );
2619 assert_eq!(
2620 super::billing_tier_label(&app, off).as_deref(),
2621 Some("off-peak")
2622 );
2623 let ids: Vec<InfoSegmentId> = super::info_segments(&app, 200)
2624 .iter()
2625 .map(|segment| segment.id)
2626 .collect();
2627 assert!(ids.contains(&InfoSegmentId::BillingTier), "{ids:?}");
2628 let row = metrics_row(&app, 200);
2629 assert!(row.contains("peak"), "the tier reads in the row: {row:?}");
2630
2631 // A flat-priced DeepSeek model has no tier to show.
2632 app.model = "deepseek-chat".to_string();
2633 assert_eq!(super::billing_tier_label(&app, peak), None);
2634 let ids: Vec<InfoSegmentId> = super::info_segments(&app, 200)
2635 .iter()
2636 .map(|segment| segment.id)
2637 .collect();
2638 assert!(!ids.contains(&InfoSegmentId::BillingTier), "{ids:?}");
2639
2640 // Another vendor serving a DeepSeek id is priced on its own terms.
2641 app.model = "deepseek-v4-flash".to_string();
2642 app.api_provider = ApiProvider::Openai;
2643 assert_eq!(super::billing_tier_label(&app, peak), None);
2644
2645 // Auto routing has not pinned a model, so there is nothing to claim.
2646 app.api_provider = ApiProvider::Deepseek;
2647 app.auto_model = true;
2648 assert_eq!(super::billing_tier_label(&app, peak), None);
2649 }
2650
2651 /// A provider switch must not hide missing historical coverage.
2652 #[test]
2653 fn cost_unknown_preserves_saved_coverage_across_route_changes() {
2654 use crate::route_billing::BillingPresentation;
2655 let mut app = app_with_context_percent(10);
2656 app.session.cost_coverage_unknown_legacy = true;
2657
2658 app.billing_presentation = BillingPresentation::Metered;
2659 assert!(matches!(
2660 app.cumulative_usage_chip(),
2661 crate::route_billing::UsageChip::Unknown(_)
2662 ));
2663 assert_eq!(
2664 super::session_cost_label(&app),
2665 "cost: unknown (saved coverage unavailable)"
2666 );
2667 let row = metrics_row(&app, 200);
2668 assert!(
2669 row.contains("cost: unknown"),
2670 "a priceable route keeps the honesty: {row:?}"
2671 );
2672
2673 app.billing_presentation = BillingPresentation::Unknown;
2674 assert!(matches!(
2675 app.cumulative_usage_chip(),
2676 crate::route_billing::UsageChip::Unknown(_)
2677 ));
2678 assert_eq!(
2679 super::session_cost_label(&app),
2680 "cost: unknown (saved coverage unavailable)"
2681 );
2682 let ids: Vec<InfoSegmentId> = super::info_segments(&app, 200)
2683 .iter()
2684 .map(|segment| segment.id)
2685 .collect();
2686 assert!(ids.contains(&InfoSegmentId::Cost), "{ids:?}");
2687 let row = metrics_row(&app, 200);
2688 assert!(
2689 row.contains("saved coverage unavailable"),
2690 "an unclassified route preserves the reason: {row:?}"
2691 );
2692 assert!(row.contains("ctx 10%"), "and nothing else moves: {row:?}");
2693
2694 // A real price on an otherwise unclassified route still prints.
2695 app.session.cost_coverage_unknown_legacy = false;
2696 app.session.cost_priced_turns = 1;
2697 app.session.session_cost = 0.42;
2698 assert!(
2699 matches!(
2700 app.cumulative_usage_chip(),
2701 crate::route_billing::UsageChip::Money(_)
2702 ),
2703 "{:?}",
2704 app.cumulative_usage_chip()
2705 );
2706 assert!(!super::session_cost_label(&app).is_empty());
2707 }
2708
2709 #[test]
2710 fn metrics_line_uses_measured_request_average_during_tool_waits_and_live_text() {
2711 use crate::tui::session_metrics::{full_text, snapshot_from_app};
2712
2713 let mut app = app_with_context_percent(60);
2714 app.ui_locale = codewhale_localization::Locale::En;
2715 app.status_items = vec![StatusItem::SessionMetrics, StatusItem::Tokens];
2716 app.is_loading = true;
2717 app.turn_started_at = Some(std::time::Instant::now() - std::time::Duration::from_secs(120));
2718 app.streaming_output_token_estimate = 60_000;
2719 assert!(
2720 super::info_segments(&app, 200)
2721 .iter()
2722 .all(|segment| segment.id != InfoSegmentId::Rate),
2723 "live text estimates do not invent measured request throughput"
2724 );
2725 app.session_metrics
2726 .record_model_call(120, 4_800, Some(1_000), Some(5_000));
2727 let rate = |app: &App| {
2728 super::info_segments(app, 200)
2729 .into_iter()
2730 .find(|segment| segment.id == InfoSegmentId::Rate)
2731 .map(|segment| segment.value)
2732 };
2733 assert_eq!(rate(&app).as_deref(), Some("24 avg tok/s"));
2734 let detailed = full_text(snapshot_from_app(&app), app.ui_locale, false);
2735 assert!(detailed.contains("24 avg tok/s"), "{detailed}");
2736
2737 // Finishing a long turn or replacing the displayed token receipt must
2738 // not switch the rate to the turn timer (which includes tool waits).
2739 app.is_loading = false;
2740 app.session.last_completion_tokens = Some(9_000);
2741 assert_eq!(rate(&app).as_deref(), Some("24 avg tok/s"));
2742 app.status_items = vec![StatusItem::Tokens];
2743 assert_eq!(rate(&app), None, "the existing status toggle still owns it");
2744 }
2745
2746 #[test]
2747 fn default_compact_footer_keeps_measured_performance_at_working_widths() {
2748 use ratatui::{Terminal, backend::TestBackend};
2749 let mut app = app_with_context_percent(60);
2750 app.ui_locale = codewhale_localization::Locale::En;
2751 app.status_items = StatusItem::default_footer();
2752 app.metrics_line = crate::config::ChromeRowPreset::Compact;
2753 app.session_metrics
2754 .record_model_call(120, 4_800, Some(1_000), Some(5_000));
2755 for width in [80, 100, 140] {
2756 let mut terminal = Terminal::new(TestBackend::new(width, 1)).unwrap();
2757 terminal
2758 .draw(|frame| {
2759 super::render_info_row(frame, &mut app, frame.area(), false);
2760 })
2761 .unwrap();
2762 let row: String = terminal
2763 .backend()
2764 .buffer()
2765 .content()
2766 .iter()
2767 .map(|cell| cell.symbol())
2768 .collect();
2769 assert!(row.contains("ttft 1.0s"), "{width}: {row}");
2770 assert!(row.contains("24 avg tok/s"), "{width}: {row}");
2771 assert!(!row.contains("/help"), "{width}: {row}");
2772 }
2773 }
2774
2775 #[test]
2776 fn performance_readings_can_be_selected_independently() {
2777 let mut app = app_with_context_percent(60);
2778 app.session_metrics
2779 .record_model_call(120, 4_800, Some(1_000), Some(5_000));
2780 for (item, expected) in [
2781 (StatusItem::Ttft, InfoSegmentId::Ttft),
2782 (StatusItem::OutputRate, InfoSegmentId::Rate),
2783 ] {
2784 app.status_items = vec![item];
2785 let ids: Vec<_> = super::info_segments(&app, 80)
2786 .into_iter()
2787 .map(|s| s.id)
2788 .collect();
2789 assert_eq!(ids, vec![expected]);
2790 }
2791 }
2792
2793 /// Every remaining status item owns a segment, and an empty list leaves
2794 /// the row with nothing but the help hint — no toggle in `/statusline`
2795 /// paints something no toggle can remove.
2796 #[test]
2797 fn every_metrics_segment_answers_to_a_status_item() {
2798 let mut app = app_with_context_percent(60);
2799 app.session.last_prompt_tokens = Some(1_000);
2800 app.session_metrics
2801 .record_model_call(1_200, 30_000, Some(400), Some(30_400));
2802 app.streaming_output_token_estimate = 1_200;
2803 app.is_loading = true;
2804 app.turn_started_at = Some(std::time::Instant::now() - std::time::Duration::from_secs(30));
2805 *app.balance_cell.lock().expect("balance cell") = Some(crate::pricing::BalanceInfo {
2806 currency: "USD".to_string(),
2807 total_balance: "4.32".to_string(),
2808 topped_up_balance: String::new(),
2809 granted_balance: String::new(),
2810 });
2811 app.status_items = StatusItem::all().to_vec();
2812 app.workspace_context = Some("main | clean".to_string());
2813
2814 let ids: Vec<InfoSegmentId> = super::info_segments(&app, 200)
2815 .iter()
2816 .map(|segment| segment.id)
2817 .collect();
2818 for expected in [
2819 InfoSegmentId::Model,
2820 InfoSegmentId::Context,
2821 InfoSegmentId::Balance,
2822 InfoSegmentId::Ttft,
2823 InfoSegmentId::Rate,
2824 InfoSegmentId::OutputTokens,
2825 InfoSegmentId::Workspace,
2826 InfoSegmentId::GitBranch,
2827 ] {
2828 assert!(ids.contains(&expected), "{expected:?} missing from {ids:?}");
2829 }
2830
2831 app.status_items = Vec::new();
2832 assert!(
2833 super::info_segments(&app, 200).is_empty(),
2834 "an empty status list leaves the metrics line empty"
2835 );
2836 }
2837
2838 #[test]
2839 fn empty_session_keeps_opted_in_workspace_identity_visible() {
2840 let mut app = app_with_context_percent(0);
2841 app.workspace = std::path::PathBuf::from("/fixture/checkout");
2842 app.workspace_context = Some("feature-6112 | clean".to_string());
2843 app.status_items = vec![StatusItem::Workspace, StatusItem::GitBranch];
2844 app.metrics_line = crate::config::ChromeRowPreset::Compact;
2845 let backend = ratatui::backend::TestBackend::new(100, 1);
2846 let mut terminal = ratatui::Terminal::new(backend).unwrap();
2847 terminal
2848 .draw(|frame| {
2849 let area = frame.area();
2850 super::render_info_row(frame, &mut app, area, true);
2851 })
2852 .unwrap();
2853 let rendered: String = terminal
2854 .backend()
2855 .buffer()
2856 .content()
2857 .iter()
2858 .map(|cell| cell.symbol())
2859 .collect();
2860 assert!(rendered.contains("checkout"), "{rendered}");
2861 assert!(rendered.contains("feature-6112"), "{rendered}");
2862 }
2863
2864 /// #6112: the opt-in workspace and branch chips read cached state only —
2865 /// the workspace path and the TTL-refreshed `workspace_context` string —
2866 /// so neither costs IO per frame. Outside a repository the branch chip
2867 /// degrades to absent rather than pinning a placeholder dash.
2868 #[test]
2869 fn workspace_and_git_branch_chips_follow_cached_workspace_context() {
2870 let mut app = app_with_context_percent(60);
2871 app.status_items = vec![StatusItem::Workspace, StatusItem::GitBranch];
2872
2873 let segments = super::info_segments(&app, 200);
2874 let workspace = segments
2875 .iter()
2876 .find(|segment| segment.id == InfoSegmentId::Workspace)
2877 .expect("workspace chip renders from the workspace path alone");
2878 assert_eq!(
2879 workspace.value,
2880 crate::tui::workspace_context::workspace_basename(&app.workspace)
2881 );
2882 assert!(
2883 segments
2884 .iter()
2885 .all(|segment| segment.id != InfoSegmentId::GitBranch),
2886 "outside a repository the branch chip is absent"
2887 );
2888
2889 // A detached HEAD reads in its recorded short-SHA form.
2890 app.workspace_context = Some("detached:abc1234 | clean".to_string());
2891 let branch = super::info_segments(&app, 200)
2892 .into_iter()
2893 .find(|segment| segment.id == InfoSegmentId::GitBranch)
2894 .expect("branch chip renders from cached context");
2895 assert_eq!(branch.value, "detached:abc1234");
2896 app.workspace_is_linked_worktree = true;
2897 let linked = super::info_segments(&app, 200)
2898 .into_iter()
2899 .find(|segment| segment.id == InfoSegmentId::GitBranch)
2900 .unwrap();
2901 assert_eq!(linked.value, "detached:abc1234 (wt)");
2902 assert!(!StatusItem::default_footer().contains(&StatusItem::Workspace));
2903 assert!(!StatusItem::default_footer().contains(&StatusItem::GitBranch));
2904
2905 // Off means off.
2906 app.status_items = Vec::new();
2907 assert!(super::info_segments(&app, 200).is_empty());
2908 }
2909 }
2910
2911 #[cfg(test)]
2912 mod one_owner_tests;
2913
2913 lines RUST