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