refactor(tui): delete the legacy FooterWidget rendering path

Rail unification moved footer rendering to `tui::underwater::render_footer`
(ui.rs:14074 and widgets/mod.rs are its only call sites). Everything the old
path was made of has had no production caller since; `cargo clippy
-p codewhale-tui --all-targets` reported ~60 dead_code warnings across it,
and with -Dwarnings that is a hard CI failure.

This is a deliberate deletion of a half-migrated surface, not a cleanup of
scaffolding nobody wanted. Naming what went so it can be rebuilt on purpose:

  widgets/footer.rs (whole module, 1650 lines)
    FooterProps, FooterToast, FooterWidget + its Renderable impl,
    footer_working_strip_glyph_at/_string, footer_working_label,
    footer_shell_label_chip, footer_agents_chip, footer_worked_chip,
    footer_mcp_chip, footer_permission_chip, mode_style, retry_banner_spans,
    WAVE_GLYPHS and the span width/truncate helpers.
    (The file itself was removed in 8d1074f7a, which picked up the staged
    deletion; this commit removes `mod footer;` and the re-export that still
    pointed at it, so the tree builds again.)

  footer_ui.rs (1369 -> 160 lines)
    render_footer, render_footer_from, stall_reason, provider_wait_reason,
    header_owns_live_pulse, footer_working_strip_active,
    footer_working_label_frame, active_subagent_status_label,
    ActiveToolStatusSnapshot + active_tool_status_label +
    collect_active_tool_status, and the footer_*_spans family
    (git_branch, goal, shell, prefix_stability, context_percent, cost,
    balance, session_tokens, cache, reasoning_replay, auxiliary,
    status_line), footer_state_label, active_foreground_shell_label,
    should_show_footer_cost (which was already carrying an
    `#[allow(dead_code)]` — #4785 burndown).

  ui/activity_detail.rs
    selected_detail_footer_label + activity_footer_target_cell_index, and
    the activity_target_cell_index / current_activity_cell_index /
    is_meaningful_activity_cell / activity_cell_rank chain that only they
    reached. `pub(crate) use` of the label in ui.rs removed with them.

Kept, because production still calls them: provider_wait_idle_secs and
maybe_log_provider_wait_incident (ui.rs:5304), is_noisy_subagent_progress
and friendly_subagent_progress (ui.rs:95), one_line_summary
(activity_detail.rs), active_goal_chip_state and
format_token_count_compact (underwater.rs).

Tests: 40 tests that only exercised the deleted path are removed with it
(37 in ui/tests.rs, 3 activity-footer-hint tests, plus footer_ui's own
module trimmed to the one_line_summary case). Two cost-truth tests in
app/tests.rs were *rewritten* rather than dropped — they asserted through
footer_cost_spans but were really testing `App::cumulative_usage_chip` and
`route_billing::format_usage_chip`, so they now call those directly and
keep #4797's coverage.

No behavior change: every deleted item was unreachable from `main`, which
is what dead_code proves.
This commit is contained in:
Hmbown committed 2026-08-03 22:17:58 -07:00
1 parent b9e5f7c38d
commit 146ab7f756
6 files changed
+28 -2148

No files matched your search

+12 -8
View File
@@ -1617,13 +1617,14 @@ fn subscription_route_hides_stale_session_dollars_in_footer() {
app.accrue_session_cost_estimate(CostEstimate::usd_only(12.34));
app.billing_presentation =
crate::route_billing::BillingPresentation::Subscription("Codex OAuth quota");
// Stale unaudited dollars must never render on a plan route; the footer
// shows the plan-aware usage line instead of money or silence.
let spans = crate::tui::footer_ui::footer_cost_spans(&app);
let rendered = spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
// Stale unaudited dollars must never render on a plan route; the usage
// chip carries the plan-aware line instead of money or silence.
let chip = app.cumulative_usage_chip();
assert!(
!matches!(chip, crate::route_billing::UsageChip::Money(_)),
"{chip:?}"
);
let rendered = crate::route_billing::format_usage_chip(&chip).unwrap_or_default();
assert!(!rendered.contains('$'), "{rendered}");
assert!(rendered.contains("Codex OAuth quota"), "{rendered}");
}
@@ -1653,7 +1654,10 @@ fn provider_switch_keeps_audited_cumulative_spend_visible() {
app.cumulative_usage_chip(),
crate::route_billing::UsageChip::Money(_)
));
assert!(!crate::tui::footer_ui::footer_cost_spans(&app).is_empty());
assert!(
crate::route_billing::format_usage_chip(&app.cumulative_usage_chip())
.is_some_and(|label| !label.is_empty())
);
let unknown = crate::pricing::audit_turn_cost_for_route_at(
ApiProvider::Openai,
File diff suppressed because it is too large. Load diff
+1 -2
View File
@@ -162,8 +162,7 @@ use super::widgets::{ChatWidget, ComposerWidget, Renderable};
// `crate::tui::ui::{...}` importers (mouse_ui, footer_ui) keep resolving, and
// import the ui-internal entry points used from this file's own body.
pub(crate) use self::activity_detail::{
copy_cell_to_clipboard, detail_target_label, open_details_pager_for_cell,
selected_detail_footer_label, turn_handoff_markdown,
copy_cell_to_clipboard, detail_target_label, open_details_pager_for_cell, turn_handoff_markdown,
};
use self::activity_detail::{
copy_focused_cell, detail_target_cell_index, extract_reasoning_header,
-125
View File
@@ -11,36 +11,9 @@ use crate::snapshot::SnapshotRepo;
use crate::tui::app::App;
use crate::tui::footer_ui::one_line_summary;
use crate::tui::history::{HistoryCell, ToolCell, ToolStatus};
use crate::tui::key_shortcuts;
use crate::tui::pager::PagerView;
use crate::tui::ui_text::{history_cell_to_text, truncate_line_to_width};
/// Resolve the activity cell the user is most likely asking about.
///
/// Used by the Space fold/unfold gesture and detail footer hints. It prefers
/// an explicitly selected activity cell, then a live activity in the current
/// turn, then the most recent meaningful activity across history + active
/// cells. Tool activity is intentionally rendered through the compact live
/// view so the Activity Detail pager does not become an accidental raw-output
/// dump; `v` remains the direct full tool-detail surface.
///
fn activity_target_cell_index(app: &App) -> Option<usize> {
if let Some(selected) = selected_transcript_cell_index(app)
&& app
.cell_at_virtual_index(selected)
.is_some_and(is_meaningful_activity_cell)
{
return Some(selected);
}
current_activity_cell_index(app).or_else(|| {
(0..app.virtual_cell_count()).rev().find(|&idx| {
app.cell_at_virtual_index(idx)
.is_some_and(is_meaningful_activity_cell)
})
})
}
fn selected_transcript_cell_index(app: &App) -> Option<usize> {
app.viewport
.transcript_selection
@@ -55,46 +28,6 @@ fn selected_transcript_cell_index(app: &App) -> Option<usize> {
})
}
fn current_activity_cell_index(app: &App) -> Option<usize> {
let active = app.active_cell.as_ref()?;
let base = app.history.len();
for desired_rank in [0, 1, 2] {
if let Some((entry_idx, _)) = active
.entries()
.iter()
.enumerate()
.rev()
.find(|(_, cell)| activity_cell_rank(cell) == Some(desired_rank))
{
return Some(base + entry_idx);
}
}
None
}
fn is_meaningful_activity_cell(cell: &HistoryCell) -> bool {
activity_cell_rank(cell).is_some()
}
fn activity_cell_rank(cell: &HistoryCell) -> Option<u8> {
match cell {
HistoryCell::Thinking {
streaming: true, ..
} => Some(0),
HistoryCell::Tool(tool) => match tool_status_for_activity(tool) {
Some(ToolStatus::Running) => Some(0),
Some(ToolStatus::Failed) => Some(1),
Some(ToolStatus::Hydrated) => Some(2),
Some(ToolStatus::Success) => Some(2),
None => Some(2),
},
HistoryCell::SubAgent(_) => Some(0),
HistoryCell::Error { .. } => Some(1),
HistoryCell::Thinking { .. } => Some(2),
_ => None,
}
}
/// Open the full recorded-reasoning detail pager for the selected thinking
/// block, or for the current/latest turn when no reasoning block is selected.
/// Ctrl+O routes here; only provider-supplied reasoning is shown.
@@ -613,64 +546,6 @@ pub(super) fn detail_target_cell_index(app: &App) -> Option<usize> {
.or_else(|| app.history.len().checked_sub(1))
}
pub(crate) fn selected_detail_footer_label(app: &App) -> Option<String> {
if app.viewport.transcript_selection.is_active() {
return None;
}
let cell_index = activity_footer_target_cell_index(app)?;
let cell = app.cell_at_virtual_index(cell_index)?;
let label = truncate_line_to_width(&activity_cell_label(app, cell_index, cell), 30);
let detail_hint = if app.cell_has_detail_target(cell_index) {
let noun = if matches!(cell, HistoryCell::SubAgent(_)) {
"details"
} else {
"raw details"
};
format!(
" · {}",
key_shortcuts::tool_details_shortcut_action_hint(noun)
)
} else {
String::new()
};
if matches!(cell, HistoryCell::Thinking { .. }) {
Some(format!(
"{} Reasoning detail · {label}{detail_hint}",
key_shortcuts::reasoning_detail_shortcut_label()
))
} else {
Some(format!(
"{} Turn Inspector · {label}{detail_hint}",
key_shortcuts::turn_inspector_shortcut_label()
))
}
}
fn activity_footer_target_cell_index(app: &App) -> Option<usize> {
let line_meta = app.viewport.transcript_cache.line_meta();
let start = app
.viewport
.last_transcript_top
.min(line_meta.len().saturating_sub(1));
let end = start
.saturating_add(app.viewport.last_transcript_visible.max(1))
.min(line_meta.len());
for meta in line_meta.iter().take(end).skip(start) {
let Some((cell_index, _)) = meta.cell_line() else {
continue;
};
let cell_index = app.original_cell_index_for_rendered(cell_index);
if app
.cell_at_virtual_index(cell_index)
.is_some_and(is_meaningful_activity_cell)
{
return Some(cell_index);
}
}
activity_target_cell_index(app)
}
pub(crate) fn detail_target_label(app: &App, cell_index: usize) -> Option<String> {
if let Some(detail) = app.tool_detail_record_for_cell(cell_index) {
return Some(detail.tool_name.clone());
+1 -785
View File
@@ -7,16 +7,13 @@ use crate::config::{
use crate::config_ui::{self, WebConfigSession, WebConfigSessionEvent};
use crate::core::engine::mock_engine_handle;
use crate::tui::active_cell::ActiveCell;
use crate::tui::app::{ReasoningEffort, SidebarHoverRow, SidebarHoverSection, ToolDetailRecord};
use crate::tui::app::{ReasoningEffort, ToolDetailRecord};
use crate::tui::file_mention::{
apply_mention_menu_selection, find_file_mention_completions, partial_file_mention_at_cursor,
try_autocomplete_file_mention, user_request_with_file_mentions, visible_mention_menu_entries,
};
use crate::tui::footer_ui::{
active_tool_status_label, footer_auxiliary_spans, footer_balance_spans, footer_cache_spans,
footer_session_tokens_spans, footer_state_label, footer_status_line_spans,
format_context_budget, format_token_count_compact, friendly_subagent_progress,
render_footer_from,
};
use crate::tui::history::{
ExecCell, ExecSource, GenericToolCell, HistoryCell, SubAgentCell, ToolCell, ToolStatus,
@@ -34,7 +31,6 @@ use std::path::PathBuf;
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use unicode_width::UnicodeWidthStr;
use crate::tui::selection::{SelectionAutoscroll, TranscriptSelectionPoint};
use tempfile::TempDir;
@@ -4991,53 +4987,6 @@ fn backtrack_prefill_rehydrates_attachment_rows() {
assert_eq!(app.composer_attachment_count(), 1);
}
#[test]
fn active_tool_status_label_summarizes_live_tool_group() {
let mut app = create_test_app();
app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));
let mut active = ActiveCell::new();
active.push_tool(
"exec-1",
HistoryCell::Tool(ToolCell::Exec(ExecCell {
command: "cargo test --workspace --all-features".to_string(),
status: ToolStatus::Running,
output: None,
live_output: None,
shell_task_id: None,
owner_agent_id: None,
owner_agent_name: None,
started_at: app.turn_started_at,
duration_ms: None,
stale_elapsed_since_output_ms: None,
source: ExecSource::Assistant,
interaction: None,
output_summary: None,
})),
);
active.push_tool(
"tool-2",
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "grep_files".to_string(),
status: ToolStatus::Success,
input_summary: Some("pattern: TODO".to_string()),
output: Some("done".to_string()),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})),
);
app.active_cell = Some(active);
let label = active_tool_status_label(&app, true).expect("status label");
assert!(label.contains("cargo test"));
assert!(label.contains("1 active"));
assert!(label.contains("1 done"));
assert!(label.contains(crate::tui::key_shortcuts::tool_details_shortcut_label().as_ref()));
assert!(label.contains("opens details"));
}
#[test]
fn refresh_shell_exec_live_output_finalizes_restored_missing_shell_job() {
let mut app = create_test_app();
@@ -5380,66 +5329,6 @@ fn shell_live_output_update_skips_finalized_exec_cell() {
assert!(shell_exec_live_update(&app, 0, &jobs).is_none());
}
#[test]
fn active_tool_status_label_strips_shell_wrappers_from_ci_polling() {
let mut app = create_test_app();
app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));
let mut active = ActiveCell::new();
active.push_tool(
"exec-1",
HistoryCell::Tool(ToolCell::Exec(ExecCell {
command: "cd /tmp/repo && sleep 15 && gh pr checks 1611 --repo Hmbown/CodeWhale"
.to_string(),
status: ToolStatus::Running,
output: None,
live_output: None,
shell_task_id: None,
owner_agent_id: None,
owner_agent_name: None,
started_at: app.turn_started_at,
duration_ms: None,
stale_elapsed_since_output_ms: None,
source: ExecSource::Assistant,
interaction: None,
output_summary: None,
})),
);
app.active_cell = Some(active);
let label = active_tool_status_label(&app, true).expect("status label");
assert!(label.contains("gh pr checks 1611"), "label: {label}");
assert!(!label.contains("cd /tmp"), "label: {label}");
assert!(!label.contains("sleep 15"), "label: {label}");
}
#[test]
fn active_tool_status_label_counts_foreground_rlm_work() {
let mut app = create_test_app();
app.turn_started_at = Some(Instant::now() - Duration::from_secs(5));
let mut active = ActiveCell::new();
active.push_tool(
"rlm-1",
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "rlm".to_string(),
status: ToolStatus::Running,
input_summary: Some("task: compare projects".to_string()),
output: None,
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})),
);
app.active_cell = Some(active);
let label = active_tool_status_label(&app, true).expect("status label");
assert!(label.contains("rlm"), "label: {label}");
assert!(!label.contains("tool rlm"), "label: {label}");
assert!(label.contains("1 active"), "label: {label}");
}
#[test]
fn terminal_probe_timeout_defaults_to_500ms() {
let config = Config::default();
@@ -8774,13 +8663,6 @@ fn init_git_repo() -> TempDir {
dir
}
fn spans_text(spans: &[Span<'_>]) -> String {
spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
#[test]
fn ctrl_alt_4_selects_pinned_rail_panel_without_switching_modes() {
let mut app = create_test_app();
@@ -9754,22 +9636,6 @@ fn fanout_interrupted_mailbox_drops_running_count() {
);
}
#[test]
fn stall_reason_provider_wait_includes_route_and_idle_budget() {
let mut app = create_test_app();
app.is_loading = true;
app.stream_chunk_timeout_secs = 300;
// Set idle to 65s so it exceeds the 60s threshold (#3189).
app.turn_started_at = Some(Instant::now() - Duration::from_secs(70));
app.turn_last_activity_at = Some(Instant::now() - Duration::from_secs(65));
let reason = crate::tui::footer_ui::stall_reason(&app).expect("stalled turn has a reason");
assert!(reason.contains("waiting for model"), "{reason}");
// idle >= 60s, so the counter appears, but < 75% budget (225s) so no budget detail.
assert!(reason.contains("1m 05s"), "{reason}");
assert!(!reason.contains("/5m 00s"), "{reason}");
}
#[test]
fn reconcile_syncs_stale_running_cards_with_interrupted_snapshots() {
let mut app = create_test_app();
@@ -9811,49 +9677,6 @@ fn reconcile_syncs_stale_running_cards_with_interrupted_snapshots() {
assert_eq!(running_agent_count(&app), 1);
}
#[test]
fn stall_reason_provider_wait_reports_zero_running_for_planned_fanout() {
let mut app = create_test_app();
app.is_loading = true;
app.turn_started_at = Some(Instant::now() - Duration::from_secs(45));
// A fanout plan exists (card seeded with pending workers) but no child
// agent has launched yet: the reason must say 0 running explicitly.
let card = crate::tui::widgets::agent_card::FanoutCard::new("rlm")
.with_workers(["task:a", "task:b", "task:c", "task:d"]);
app.history
.push(HistoryCell::SubAgent(SubAgentCell::Fanout(card)));
app.history_revisions.push(0);
app.last_fanout_card_index = Some(app.history.len() - 1);
let reason = crate::tui::footer_ui::stall_reason(&app).expect("stalled turn has a reason");
assert!(reason.contains("fanout 0/4"), "{reason}");
// Once a worker is actually running the marker disappears.
if let Some(HistoryCell::SubAgent(SubAgentCell::Fanout(card))) = app
.last_fanout_card_index
.and_then(|idx| app.history.get_mut(idx))
{
card.upsert_worker(
"task:a",
crate::tui::widgets::agent_card::AgentLifecycle::Running,
);
}
let reason = crate::tui::footer_ui::stall_reason(&app).expect("stalled turn has a reason");
assert!(!reason.contains("fanout 0/4"), "{reason}");
}
#[test]
fn stall_reason_provider_wait_flags_pending_dispatch() {
let mut app = create_test_app();
app.is_loading = true;
app.turn_started_at = Some(Instant::now() - Duration::from_secs(31));
app.pending_subagent_dispatch = Some("agent".to_string());
let reason = crate::tui::footer_ui::stall_reason(&app).expect("stalled turn has a reason");
assert!(reason.contains("dispatch pending"), "{reason}");
}
#[test]
fn provider_wait_incident_logs_once_per_turn() {
let mut app = create_test_app();
@@ -9887,72 +9710,12 @@ fn format_token_count_compact_formats_units() {
assert_eq!(format_token_count_compact(1_000_000), "1.0M");
}
#[test]
fn footer_session_tokens_chip_uses_single_compact_total() {
let mut app = create_test_app();
app.session.total_input_tokens = 900_000;
app.session.total_cache_hit_tokens = 700_000;
app.session.total_cache_miss_tokens = 200_000;
app.session.total_output_tokens = 600_000;
let text = spans_text(&footer_session_tokens_spans(&app));
assert_eq!(text, "tok 1.5M");
assert!(!text.contains(" cch "));
assert!(!text.contains(" out"));
}
#[test]
fn footer_session_tokens_chip_shows_token_count_without_throughput() {
// Throughput display ("out ~12/s live") was removed — the token count
// is enough. See commit history for rationale.
let mut app = create_test_app();
app.is_loading = true;
app.turn_started_at = Some(Instant::now() - Duration::from_secs(10));
app.streaming_output_token_estimate = 125;
let text = spans_text(&footer_session_tokens_spans(&app));
// No tokens accumulated yet (only streaming estimate), so chip is empty.
assert_eq!(text, "");
}
#[test]
fn footer_session_tokens_chip_shows_compact_total() {
let mut app = create_test_app();
app.session.total_input_tokens = 1_000;
app.session.total_output_tokens = 240;
app.session.last_output_throughput =
crate::resource_telemetry::TokenThroughput::new(240, Duration::from_secs(12));
let text = spans_text(&footer_session_tokens_spans(&app));
// Token count shown; throughput NOT appended.
assert_eq!(text, "tok 1.2k");
assert!(!text.contains("out"));
assert!(!text.contains("/s"));
}
#[test]
fn format_context_budget_caps_overflow_display() {
assert_eq!(format_context_budget(5_000, 128_000), "5.0k/128.0k");
assert_eq!(format_context_budget(250_000, 128_000), ">128.0k/128.0k");
}
#[test]
fn footer_state_label_shows_idle_ready_and_prefers_compacting() {
// Header ● Live owns coarse busy state during streaming turns.
let mut app = create_test_app();
assert_eq!(footer_state_label(&app).0, "idle");
app.is_loading = true;
assert_eq!(footer_state_label(&app).0, "ready");
assert_ne!(footer_state_label(&app).0, "thinking");
app.is_compacting = true;
assert!(footer_state_label(&app).0.starts_with("compacting"));
}
#[test]
fn event_poll_timeout_has_nonzero_floor() {
assert_eq!(
@@ -10268,162 +10031,6 @@ fn slow_external_url_command() -> Command {
command
}
#[test]
fn footer_status_line_spans_show_model_idle_and_active() {
let mut app = create_test_app();
app.model = "deepseek-v4-flash".to_string();
let idle = spans_text(&footer_status_line_spans(&app, 60));
assert!(idle.contains("deepseek-v4-flash"));
assert!(idle.contains("\u{00B7}"));
assert!(idle.contains("idle"));
assert!(!idle.contains("act"));
assert!(!idle.contains("agent"));
// Header ● Live owns coarse busy state; footer defers to action detail.
app.is_loading = true;
let active = spans_text(&footer_status_line_spans(&app, 60));
assert!(active.contains("deepseek-v4-flash"));
assert!(
!active.contains("busy"),
"footer must not repeat coarse busy when header streams: {active}"
);
assert!(
!active.contains("thinking"),
"footer must not show a `thinking` text label while loading"
);
}
#[test]
fn footer_status_line_spans_truncate_long_model_names() {
let mut app = create_test_app();
app.model = "deepseek-v4-pro-with-an-extremely-long-model-name".to_string();
app.is_loading = true;
let line = spans_text(&footer_status_line_spans(&app, 40));
assert!(line.contains("..."));
assert!(UnicodeWidthStr::width(line.as_str()) <= 40);
}
#[test]
fn footer_auxiliary_spans_show_cache_when_compact() {
let mut app = create_test_app();
app.is_loading = true;
app.session.last_prompt_tokens = Some(48_000);
app.session.last_prompt_cache_hit_tokens = Some(36_000);
app.session.last_prompt_cache_miss_tokens = Some(12_000);
app.session.session_cost = 12.34;
let compact = spans_text(&footer_auxiliary_spans(&app, 48));
assert!(compact.contains("Cache: 75.0% hit"));
assert!(!compact.contains('$'));
}
#[test]
fn footer_auxiliary_spans_show_cache_unavailable_when_provider_omits_cache_fields() {
let mut app = create_test_app();
app.session.last_prompt_tokens = Some(48_000);
app.session.last_completion_tokens = Some(2_000);
let roomy = spans_text(&footer_auxiliary_spans(&app, 72));
assert!(roomy.contains("Cache: unavailable"));
}
#[test]
fn footer_auxiliary_spans_show_cache_and_cost_when_roomy() {
let mut app = create_test_app();
app.session.last_prompt_tokens = Some(48_000);
app.session.last_prompt_cache_hit_tokens = Some(36_000);
app.session.last_prompt_cache_miss_tokens = Some(12_000);
app.session.session_cost = 12.34;
let roomy = spans_text(&footer_auxiliary_spans(&app, 72));
assert!(roomy.contains("Cache: 75.0% hit | hit 36000 | miss 12000"));
assert!(roomy.contains("$12.34"));
assert!(
!roomy.contains("ctx"),
"context % removed from footer — shown in header only"
);
}
#[test]
fn footer_cache_low_hit_with_stable_prefix_is_not_error_colored() {
let mut app = create_test_app();
app.session.last_prompt_tokens = Some(10_000);
app.session.last_prompt_cache_hit_tokens = Some(500);
app.session.last_prompt_cache_miss_tokens = Some(9_500);
app.prefix_stability_pct = Some(100);
app.prefix_change_count = 0;
let spans = footer_cache_spans(&app);
assert_eq!(spans_text(&spans), "Cache: 5.0% hit | hit 500 | miss 9500");
assert_eq!(spans[0].style.fg, Some(palette::TEXT_MUTED));
}
#[test]
fn footer_cache_low_hit_with_prefix_churn_stays_error_colored() {
let mut app = create_test_app();
app.session.last_prompt_tokens = Some(10_000);
app.session.last_prompt_cache_hit_tokens = Some(500);
app.session.last_prompt_cache_miss_tokens = Some(9_500);
app.prefix_stability_pct = Some(80);
app.prefix_change_count = 2;
let spans = footer_cache_spans(&app);
assert_eq!(spans[0].style.fg, Some(palette::STATUS_ERROR));
}
#[test]
fn footer_auxiliary_spans_show_tiny_positive_cost_when_roomy() {
let mut app = create_test_app();
app.session.session_cost = 0.00005;
let roomy = spans_text(&footer_auxiliary_spans(&app, 32));
assert!(roomy.contains("<$0.0001"));
}
#[test]
fn footer_auxiliary_spans_use_configured_cost_currency() {
let mut app = create_test_app();
app.cost_currency = crate::pricing::CostCurrency::Cny;
app.session.session_cost_cny = 2.5;
let roomy = spans_text(&footer_auxiliary_spans(&app, 32));
assert!(roomy.contains("¥2.50"));
assert!(!roomy.contains('$'));
}
#[test]
fn footer_auxiliary_spans_show_reasoning_replay_chip() {
// Issue #30: when a thinking-mode tool-calling turn replays prior
// reasoning_content, the footer surfaces the approximate input-token
// cost so users can see why their context filled up.
let mut app = create_test_app();
app.session.last_prompt_tokens = Some(48_000);
app.session.last_reasoning_replay_tokens = Some(8_200);
let spans = footer_auxiliary_spans(&app, 64);
let text = spans_text(&spans);
assert!(
text.contains("rsn 8.2k"),
"expected replay chip, got {text:?}"
);
}
#[test]
fn footer_auxiliary_spans_hide_reasoning_replay_when_zero() {
let mut app = create_test_app();
app.session.last_prompt_tokens = Some(48_000);
app.session.last_reasoning_replay_tokens = Some(0);
let spans = footer_auxiliary_spans(&app, 64);
let text = spans_text(&spans);
assert!(!text.contains("rsn"), "zero replay must not render chip");
}
#[test]
fn context_usage_snapshot_prefers_estimate_when_reported_exceeds_window() {
let mut app = create_test_app();
@@ -12796,134 +12403,6 @@ fn pop_pager_body(app: &mut App) -> String {
pager.body_text()
}
#[test]
fn detail_target_prefers_visible_tool_card() {
let mut app = create_test_app();
app.history = vec![
HistoryCell::User {
content: "hello".to_string(),
},
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "file_search".to_string(),
status: ToolStatus::Success,
input_summary: Some("query: foo".to_string()),
output: Some("done".to_string()),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})),
HistoryCell::Assistant {
content: "ok".to_string(),
streaming: false,
},
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: "exec_shell".to_string(),
status: ToolStatus::Success,
input_summary: Some("command: ls".to_string()),
output: Some("...".to_string()),
prompts: None,
spillover_path: None,
output_summary: None,
is_diff: false,
})),
];
app.tool_details_by_cell.insert(
1,
ToolDetailRecord {
tool_id: "search-1".to_string(),
tool_name: "file_search".to_string(),
input: serde_json::json!({"query": "foo"}),
output: Some("done".to_string()),
},
);
app.tool_details_by_cell.insert(
3,
ToolDetailRecord {
tool_id: "exec-1".to_string(),
tool_name: "exec_shell".to_string(),
input: serde_json::json!({"command": "ls"}),
output: Some("...".to_string()),
},
);
app.resync_history_revisions();
let revisions = app.history_revisions.clone();
app.viewport.transcript_cache.ensure(
&app.history,
&revisions,
100,
app.transcript_render_options(),
);
app.viewport.last_transcript_top = first_line_for_cell(&app, 1);
app.viewport.last_transcript_visible = 6;
assert_eq!(detail_target_cell_index(&app), Some(1));
let expected = format!(
"{} Turn Inspector · find · {}",
crate::tui::key_shortcuts::turn_inspector_shortcut_label(),
crate::tui::key_shortcuts::tool_details_shortcut_action_hint("raw details")
);
assert_eq!(
selected_detail_footer_label(&app).as_deref(),
Some(expected.as_str())
);
}
#[test]
fn activity_footer_hint_surfaces_visible_thinking_without_raw_tool_hint() {
let mut app = create_test_app();
app.history = vec![HistoryCell::Thinking {
content: "visible reasoning".to_string(),
streaming: false,
duration_secs: Some(1.4),
}];
app.resync_history_revisions();
let revisions = app.history_revisions.clone();
app.viewport.transcript_cache.ensure(
&app.history,
&revisions,
100,
app.transcript_render_options(),
);
app.viewport.last_transcript_top = first_line_for_cell(&app, 0);
app.viewport.last_transcript_visible = 4;
assert_eq!(
selected_detail_footer_label(&app).as_deref(),
Some("Ctrl+O Reasoning detail · thinking")
);
}
#[test]
fn activity_footer_hint_uses_details_for_subagent_cards() {
let mut app = create_test_app();
app.history = vec![HistoryCell::SubAgent(
crate::tui::history::SubAgentCell::Delegate(
crate::tui::widgets::agent_card::DelegateCard::new("agent_123", "general"),
),
)];
app.resync_history_revisions();
let revisions = app.history_revisions.clone();
app.viewport.transcript_cache.ensure(
&app.history,
&revisions,
100,
app.transcript_render_options(),
);
app.viewport.last_transcript_top = first_line_for_cell(&app, 0);
app.viewport.last_transcript_visible = 4;
let expected = format!(
"{} Turn Inspector · sub-agent · {}",
crate::tui::key_shortcuts::turn_inspector_shortcut_label(),
crate::tui::key_shortcuts::tool_details_shortcut_action_hint("details")
);
assert_eq!(
selected_detail_footer_label(&app).as_deref(),
Some(expected.as_str())
);
}
#[test]
fn macos_option_v_glyph_is_treated_as_details_shortcut_only_on_macos() {
let option_v = KeyEvent::new(KeyCode::Char('\u{221A}'), KeyModifiers::NONE);
@@ -18428,20 +17907,6 @@ fn build_pending_input_preview_includes_current_context_chips() {
);
}
#[test]
fn render_footer_from_with_default_items_leaves_header_owned_facts_out() {
// Header owns model and mode; footer shows cost/status only.
let mut app = create_test_app();
app.session.session_cost = 0.00005;
let items = crate::config::StatusItem::default_footer();
let props = render_footer_from(&app, &items, None);
assert!(props.mode_label.is_empty(), "footer should not repeat mode");
assert!(props.model.is_empty(), "footer should not repeat model");
// Tiny but real costs should render instead of disappearing as "$0.00".
assert!(!props.cost.is_empty());
assert_eq!(spans_text(&props.cost), "<$0.0001");
}
#[test]
fn default_footer_excludes_provider_specific_diagnostic_chips() {
let items = crate::config::StatusItem::default_footer();
@@ -18464,227 +17929,8 @@ fn default_footer_excludes_provider_specific_diagnostic_chips() {
);
}
#[test]
fn render_footer_from_prefix_stability_item_renders_cache_slot_chip() {
let mut app = create_test_app();
app.prefix_stability_pct = Some(100);
app.prefix_change_count = 0;
let props = render_footer_from(&app, &[crate::config::StatusItem::PrefixStability], None);
assert_eq!(spans_text(&props.cache), "cache prefix 100%");
}
#[test]
fn render_footer_from_preserves_prefix_then_cache_order() {
let mut app = create_test_app();
app.prefix_stability_pct = Some(100);
app.prefix_change_count = 0;
app.session.last_prompt_tokens = Some(10_000);
app.session.last_prompt_cache_hit_tokens = Some(9_000);
app.session.last_prompt_cache_miss_tokens = Some(1_000);
let props = render_footer_from(
&app,
&[
crate::config::StatusItem::PrefixStability,
crate::config::StatusItem::Cache,
],
None,
);
assert!(spans_text(&props.cache).starts_with("cache prefix 100% Cache: 90.0% hit"));
}
#[test]
fn render_footer_from_with_empty_items_blanks_every_segment() {
// A user who toggles every chip OFF should get a bare footer (no model
// text, no cost, no auxiliary chips). This is the explicit-empty case.
let mut app = create_test_app();
app.session.session_cost = 1.5;
let props = render_footer_from(&app, &[], None);
assert_eq!(props.mode_label, "");
assert!(props.model.is_empty());
assert!(props.cost.is_empty());
assert!(props.agents.is_empty());
assert!(props.cache.is_empty());
}
#[test]
fn render_footer_from_surfaces_background_shell_even_without_tasks_panel() {
let mut app = create_test_app();
app.task_panel = vec![crate::tui::app::TaskPanelEntry {
id: "shell_abc".to_string(),
status: "running".to_string(),
prompt_summary: "shell: cargo test -p codewhale-tui".to_string(),
duration_ms: Some(5_000),
kind: crate::tui::app::TaskPanelEntryKind::Background,
stale: false,
elapsed_since_output_ms: None,
owner_agent_id: None,
owner_agent_name: None,
current_tool: None,
role: None,
files_touched: 0,
}];
let props = render_footer_from(&app, &[], None);
let shell = spans_text(&props.cache);
assert!(shell.contains("shell bg:"), "{shell}");
assert!(shell.contains("cargo test"), "{shell}");
}
#[test]
fn render_footer_from_drops_only_unselected_clusters() {
// Toggling Cost off but keeping the rest should hide cost only.
let mut app = create_test_app();
app.session.session_cost = 0.42;
let items: Vec<crate::config::StatusItem> = crate::config::StatusItem::default_footer()
.into_iter()
.filter(|item| *item != crate::config::StatusItem::Cost)
.collect();
let props = render_footer_from(&app, &items, None);
assert!(props.mode_label.is_empty());
assert!(props.model.is_empty(), "footer should not repeat model");
assert!(
props.cost.is_empty(),
"cost cluster should be empty when Cost is disabled"
);
}
#[test]
fn render_footer_from_git_branch_item_renders_workspace_branch() {
let repo = init_git_repo();
let checkout = Command::new("git")
.args(["checkout", "-b", "feature/statusline"])
.current_dir(repo.path())
.output()
.expect("git checkout should run");
assert!(
checkout.status.success(),
"git checkout failed: {}",
String::from_utf8_lossy(&checkout.stderr)
);
let mut app = create_test_app();
app.workspace = repo.path().to_path_buf();
crate::tui::workspace_context::refresh_if_needed(&mut app, Instant::now(), true);
let props = render_footer_from(&app, &[crate::config::StatusItem::GitBranch], None);
// #3188: the chip now leads with the workspace repo identity, then the
// branch. The temp repo basename is random, so assert the stable shape:
// a `Repo:` prefix joined to the actual branch via " @ ".
let chip = spans_text(&props.cache);
assert!(
chip.starts_with("Repo: "),
"chip should name the repo: {chip:?}"
);
assert!(
chip.ends_with(" @ feature/statusline"),
"chip should show the current branch: {chip:?}"
);
}
// ── Balance footer chip tests ─────────────────────────────────────
#[test]
fn footer_balance_spans_empty_when_cell_is_none() {
let app = create_test_app();
let spans = footer_balance_spans(&app);
assert!(spans.is_empty());
}
#[test]
fn footer_balance_spans_empty_when_balance_is_zero() {
let app = create_test_app();
let info = crate::pricing::BalanceInfo {
currency: "USD".into(),
total_balance: "0".into(),
..Default::default()
};
*app.balance_cell.lock().unwrap() = Some(info);
let spans = footer_balance_spans(&app);
assert!(spans.is_empty());
}
#[test]
fn footer_balance_spans_formats_cny() {
let app = create_test_app();
let info = crate::pricing::BalanceInfo {
currency: "CNY".into(),
total_balance: "123.45".into(),
..Default::default()
};
*app.balance_cell.lock().unwrap() = Some(info);
let spans = footer_balance_spans(&app);
assert_eq!(spans_text(&spans), "balance ¥123.5");
}
#[test]
fn footer_balance_spans_formats_usd() {
let app = create_test_app();
let info = crate::pricing::BalanceInfo {
currency: "USD".into(),
total_balance: "0.50".into(),
..Default::default()
};
*app.balance_cell.lock().unwrap() = Some(info);
let spans = footer_balance_spans(&app);
assert_eq!(spans_text(&spans), "balance $0.50");
}
#[test]
fn footer_balance_spans_rounds_large_amount() {
let app = create_test_app();
let info = crate::pricing::BalanceInfo {
currency: "USD".into(),
total_balance: "1234.56".into(),
..Default::default()
};
*app.balance_cell.lock().unwrap() = Some(info);
let spans = footer_balance_spans(&app);
assert_eq!(spans_text(&spans), "balance $1235");
}
#[test]
fn footer_balance_spans_treats_unknown_currency_as_usd() {
let app = create_test_app();
let info = crate::pricing::BalanceInfo {
currency: "EUR".into(),
total_balance: "10.00".into(),
..Default::default()
};
*app.balance_cell.lock().unwrap() = Some(info);
let spans = footer_balance_spans(&app);
assert_eq!(spans_text(&spans), "balance $10.0");
}
#[test]
fn render_footer_from_with_balance_item_shows_balance() {
let app = create_test_app();
let info = crate::pricing::BalanceInfo {
currency: "USD".into(),
total_balance: "42.50".into(),
..Default::default()
};
*app.balance_cell.lock().unwrap() = Some(info);
let props = render_footer_from(&app, &[crate::config::StatusItem::Balance], None);
assert_eq!(spans_text(&props.balance), "balance $42.5");
}
#[test]
fn render_footer_from_without_balance_item_hides_balance() {
let app = create_test_app();
let info = crate::pricing::BalanceInfo {
currency: "USD".into(),
total_balance: "99.99".into(),
..Default::default()
};
*app.balance_cell.lock().unwrap() = Some(info);
let props = render_footer_from(&app, &[], None);
assert!(spans_text(&props.balance).is_empty());
}
#[test]
fn should_fetch_deepseek_balance_requires_balance_status_item() {
let mut app = create_test_app();
@@ -18709,36 +17955,6 @@ fn should_fetch_deepseek_balance_requires_deepseek_provider() {
assert!(should_fetch_deepseek_balance(&app));
}
#[test]
fn default_footer_renders_workspace_branch_when_available() {
let repo = init_git_repo();
let checkout = Command::new("git")
.args(["checkout", "-b", "feature/default-branch-chip"])
.current_dir(repo.path())
.output()
.expect("git checkout should run");
assert!(
checkout.status.success(),
"git checkout failed: {}",
String::from_utf8_lossy(&checkout.stderr)
);
let mut app = create_test_app();
app.workspace = repo.path().to_path_buf();
crate::tui::workspace_context::refresh_if_needed(&mut app, Instant::now(), true);
let props = render_footer_from(&app, &crate::config::StatusItem::default_footer(), None);
let cache = spans_text(&props.cache);
assert!(
cache.contains("feature/default-branch-chip"),
"default footer should include the current git branch: {cache:?}"
);
assert!(
cache.contains("Repo: "),
"default footer should name the repo identity (#3188): {cache:?}"
);
}
/// Regression for issue #244: visible session spend must not decrease.
/// Sub-agent token usage events arrive out of order and may be reconciled
/// later (cache adjustments, provisional → final swap). The displayed total
+1 -6
View File
@@ -1,4 +1,3 @@
mod footer;
mod header;
// Some helpers (`shift`, `ctrl_alt`, `is_press`, etc.) are part of the
// public surface for issue #93's help overlay and future call sites; allow
@@ -12,10 +11,6 @@ mod renderable;
pub mod tool_card;
pub mod workflow_panel;
pub use footer::{
FooterProps, FooterToast, FooterWidget, footer_agents_chip, footer_shell_label_chip,
footer_working_label,
};
pub use header::header_status_indicator_frame;
pub use renderable::Renderable;
@@ -476,7 +471,7 @@ impl ChatWidget {
} else {
app.viewport.transcript_cache.lines()[top..end].to_vec()
};
let mut line_links = if total_lines == 0 {
let line_links = if total_lines == 0 {
vec![Vec::new()]
} else {
app.viewport.transcript_cache.line_links()[top..end].to_vec()