Replace per-block usage footer with persistent input status bar

Move token/cache/cost metrics from the expandable per-block usage button
into a persistent status bar rendered in the agent input area. This gives
always-visible feedback without requiring user interaction.

- Add render_session_status_bar to agent input showing context %, cache
  hit rate, and cost
- Persist cache/cost totals in ConversationUsageMetadata for session restore
- Remove render_usage_button, ToggleIsUsageFooterExpanded action, and
  UsageFooterToggled event
- Remove "Request tokens: --" debug block footer overlay
- Fix has_footer() to return false so blocks no longer reserve phantom
  footer space (root cause of padding/margin issue)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Ward
2026-06-03 00:17:34 -05:00
co-authored by Claude Opus 4.6
parent 259244863b
commit 9e11c91a6e
11 changed files with 195 additions and 403 deletions
+1 -34
View File
@@ -6,7 +6,7 @@ use crate::drive::settings::WarpDriveSettings;
use crate::features::FeatureFlag;
use crate::pane_group::SplitPaneState;
use crate::settings::{
AISettings, DebugSettings, EnforceMinimumContrast, PrivacySettings, TerminalSpacing,
AISettings, EnforceMinimumContrast, PrivacySettings, TerminalSpacing,
};
use crate::terminal::alt_screen::{should_intercept_mouse, should_intercept_scroll};
use crate::terminal::block_list_viewport::AutoscrollBehavior;
@@ -23,7 +23,6 @@ use crate::terminal::{grid_renderer, SizeInfo};
use crate::themes::theme::{Fill, GalaxyTheme};
use crate::ui_components::{self, icons as UIIcon};
use crate::util::color::Opacity;
use crate::BlocklistAIHistoryModel;
use enum_iterator::Sequence;
use galaxy_core::semantic_selection::SemanticSelection;
use galaxy_core::ui::builder::UiBuilder;
@@ -3547,38 +3546,6 @@ impl Element for BlockListElement {
// references to the terminal model
drop(viewport_iter);
if DebugSettings::as_ref(app).should_show_memory_stats() {
let history_model = BlocklistAIHistoryModel::as_ref(app);
for block_index in &visible_block_indices {
if let Some(block) = model.block_list().block_at(*block_index) {
if !block.has_footer() {
continue;
}
let request_tokens = block
.agent_view_visibility()
.agent_view_conversation_id()
.and_then(|conversation_id| history_model.conversation(&conversation_id))
.map_or(0, |conversation| conversation.last_block_total_tokens());
let text = if request_tokens > 0 {
format!("Request tokens: {request_tokens}")
} else {
"Request tokens: --".to_string()
};
let mut element = Text::new_inline(text, self.ui_font_family, self.font_size)
.with_style(Properties::default().weight(self.font_weight))
.with_color(
self.warp_theme
.sub_text_color(self.warp_theme.background())
.into(),
)
.finish();
element.layout(constraint, ctx, app);
self.block_footer_elements.insert(*block_index, element);
}
}
}
// Explicitly drop the terminal model mutex guard so that it can be freed up for other
// threads
+144 -1
View File
@@ -30,11 +30,12 @@ use galaxyui::{
Align, AnchorPair, Border, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
DispatchEventResult, DropTarget, Element, EventHandler, Flex, Hoverable, MainAxisSize,
OffsetPositioning, OffsetType, ParentElement, PositionedElementOffsetBounds,
PositioningAxis, Radius, SavePosition, Stack, XAxisAnchor, YAxisAnchor,
PositioningAxis, Radius, SavePosition, Stack, Text, XAxisAnchor, YAxisAnchor,
},
presenter::ChildView,
AppContext, SingletonEntity as _,
};
use pathfinder_color::ColorU;
pub(super) const CLOUD_MODE_V2_MAX_WIDTH: f32 = 720.;
@@ -156,6 +157,17 @@ impl Input {
.finish(),
);
if let Some(conv_id) = self
.agent_view_controller
.as_ref(app)
.agent_view_state()
.active_conversation_id()
{
if let Some(status_bar) = render_session_status_bar(appearance, app, conv_id) {
column.add_child(status_bar);
}
}
stack.add_child(wrap_input_with_terminal_padding_and_focus_handler(
self.is_active_session(app),
column.finish(),
@@ -598,6 +610,137 @@ impl Input {
}
}
fn format_token_count(tokens: u32) -> String {
if tokens >= 1_000_000 {
format!("{:.1}M", tokens as f64 / 1_000_000.0)
} else if tokens >= 1_000 {
format!("{:.1}k", tokens as f64 / 1_000.0)
} else {
format!("{tokens}")
}
}
fn cache_hit_color(pct: f64, theme: &galaxy_core::ui::theme::GalaxyTheme) -> ColorU {
if pct >= 90.0 {
theme.ansi_fg_green()
} else if pct >= 50.0 {
theme.ansi_fg_yellow()
} else {
theme.ansi_fg_red()
}
}
fn render_session_status_bar(appearance: &Appearance, app: &AppContext, conversation_id: crate::ai::agent::conversation::AIConversationId) -> Option<Box<dyn Element>> {
let (cache_read, cache_write, cache_miss, cost_cents, context_usage, current_context) =
if let Some(conversation) = BlocklistAIHistoryModel::as_ref(app).conversation(&conversation_id) {
(
conversation.total_cache_read_tokens(),
conversation.total_cache_write_tokens(),
conversation.cache_miss_tokens(),
conversation.total_cost_cents(),
conversation.context_window_usage(),
conversation.current_context_tokens(),
)
} else {
(0, 0, 0, 0.0, 0.0, 0)
};
let cache_total_ops = cache_read + cache_write + cache_miss;
let cache_hit_pct = if cache_total_ops > 0 {
(cache_read as f64 / cache_total_ops as f64) * 100.0
} else {
0.0
};
let max_context: u32 = if context_usage > 0.0 {
(current_context as f32 / context_usage).round() as u32
} else {
200_000
};
let context_pct = context_usage * 100.0;
let theme = appearance.theme();
let font_family = appearance.ui_font_family();
let font_size = appearance.monospace_font_size() - 1.0;
let dim_color: ColorU = theme.sub_text_color(theme.background()).into();
let cache_color = cache_hit_color(cache_hit_pct, theme);
let mut row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min);
// Context: XX.X% (Xk / Xk)
let context_text = format!(
"\u{25a0} Ctx: {:.1}% ({}/{})",
context_pct,
format_token_count(current_context),
format_token_count(max_context),
);
row.add_child(
Text::new_inline(context_text, font_family, font_size)
.with_color(dim_color)
.finish(),
);
// Separator
row.add_child(
Container::new(
Text::new_inline(" \u{2502} ".to_string(), font_family, font_size)
.with_color(dim_color)
.finish(),
)
.finish(),
);
// Cache Hit: XX.X% (R: Xk W: Xk M: Xk)
let cache_label = format!("\u{25c6} Cache: {:.1}%", cache_hit_pct);
row.add_child(
Text::new_inline(cache_label, font_family, font_size)
.with_color(cache_color)
.finish(),
);
let cache_detail = format!(
" (R:{} W:{} M:{})",
format_token_count(cache_read),
format_token_count(cache_write),
format_token_count(cache_miss),
);
row.add_child(
Text::new_inline(cache_detail, font_family, font_size)
.with_color(dim_color)
.finish(),
);
// Separator
row.add_child(
Container::new(
Text::new_inline(" \u{2502} ".to_string(), font_family, font_size)
.with_color(dim_color)
.finish(),
)
.finish(),
);
// Cost: $X.XX
let cost_text = format!("\u{25b2} ${:.2}", cost_cents / 100.0);
row.add_child(
Text::new_inline(cost_text, font_family, font_size)
.with_color(theme.ansi_fg_green())
.finish(),
);
Some(
Container::new(row.finish())
.with_padding_left(12.)
.with_padding_right(12.)
.with_padding_top(2.)
.with_padding_bottom(2.)
.with_background(theme.background())
.finish(),
)
}
pub mod styles {
use galaxy_core::ui::theme::GalaxyTheme;
use pathfinder_color::ColorU;
+1 -1
View File
@@ -2063,7 +2063,7 @@ impl Block {
}
pub fn has_footer(&self) -> bool {
self.show_memory_stats
false
}
pub fn footer_top_padding(&self) -> Lines {
+2 -188
View File
@@ -87,9 +87,6 @@ use crate::ai::blocklist::block::cli_controller::{
CLISubagentController, CLISubagentEvent, UserTakeOverReason,
};
use crate::ai::blocklist::block::status_bar::BlocklistAIStatusBarEvent;
use crate::ai::blocklist::usage::conversation_usage_view::{
ConversationUsageInfo, ConversationUsageView, DisplayMode, TimingInfo,
};
use crate::ai::blocklist::{block_context_from_terminal_model, SlashCommandRequest};
use crate::ai::document::ai_document_model::{AIDocumentId, AIDocumentModel, AIDocumentVersion};
use crate::ai::loading::shimmering_warp_loading_text;
@@ -2583,9 +2580,6 @@ pub struct TerminalView {
/// the `insert_rich_content` helper function.
rich_content_views: Vec<RichContent>,
/// Cached view ids for usage footers keyed by the AI block view id that owns them.
usage_footer_view_ids: HashMap<EntityId, EntityId>,
/// View ID of the context window debug view, if visible.
context_view_id: Option<EntityId>,
@@ -4094,7 +4088,6 @@ impl TerminalView {
block_filter_editor,
active_filter_editor_block_index: None,
rich_content_views: Vec::new(),
usage_footer_view_ids: Default::default(),
context_view_id: None,
settings_view_id: None,
block_onboarding_active: false,
@@ -5035,22 +5028,6 @@ impl TerminalView {
self.hide_telemetry_banner_permanently(ctx);
}
// Close any open usage footer(s) when a new AI block is added
if !self.usage_footer_view_ids.is_empty() {
let owner_block_ids: Vec<EntityId> =
self.usage_footer_view_ids.keys().copied().collect();
for owner_id in &owner_block_ids {
if let Some(ai_block_handle) = self.ai_block_handle_by_view_id(*owner_id) {
ai_block_handle.update(ctx, |block, ctx| {
block.handle_action(
&AIBlockAction::ToggleIsUsageFooterExpanded,
ctx,
);
});
}
}
}
if self.ambient_agent_view_model.as_ref(ctx).is_ambient_agent()
&& self
.model
@@ -5599,156 +5576,8 @@ impl TerminalView {
/// Handle the opening and closing of the usage footer.
/// We insert the usage footer as a rich content view into the blocklist
/// below the block that triggered the toggle event.
fn handle_usage_footer_toggled(
&mut self,
source_ai_block_view_id: EntityId,
conversation_id: AIConversationId,
is_expanded: bool,
ctx: &mut ViewContext<Self>,
) {
// Close any existing usage footer for this specific AI block
if let Some(id) = self.usage_footer_view_ids.remove(&source_ai_block_view_id) {
let mut model = self.model.lock();
model.block_list_mut().remove_rich_content(id);
drop(model);
self.rich_content_views.retain(|rc| rc.view_id() != id);
}
if !is_expanded {
// If the goal was to close the usage footer block, we've done that above
ctx.notify();
return;
}
// Get the conversation from the history model
let Some(conversation) =
BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id)
else {
log::error!("Could not find conversation for usage footer");
return;
};
let tool_usage = conversation.tool_usage_metadata();
// For Bedrock conversations, the server doesn't send tool_usage_metadata.
// Fall back to counting actions from the conversation exchanges.
let tool_call_count = if tool_usage.total_tool_calls() > 0 {
tool_usage.total_tool_calls()
} else {
conversation.count_all_actions() as i32
};
let commands_executed = if tool_usage.run_command_stats.commands_executed > 0 {
tool_usage.run_command_stats.commands_executed
} else {
conversation.count_command_actions() as i32
};
let time_to_first_token_ms = conversation.time_to_first_token_for_last_user_query_ms();
let total_agent_response_time_ms =
conversation.total_agent_response_time_since_last_user_query_ms();
let wall_to_wall_response_time_ms =
conversation.wall_to_wall_response_time_since_last_query();
let token_usage_list = conversation.total_token_usage();
let total_cache_read_tokens: u32 = token_usage_list.iter().map(|u| u.input_cache_read).sum();
let total_cache_write_tokens: u32 =
token_usage_list.iter().map(|u| u.input_cache_write).sum();
let total_input_tokens: u32 = token_usage_list.iter().map(|u| u.total_input).sum();
let estimated_cost_cents: f32 = token_usage_list.iter().map(|u| u.cost_in_cents).sum();
let conversation_usage_info = ConversationUsageInfo {
tool_calls: tool_call_count,
models: conversation.token_usage().to_vec(),
context_window_usage: conversation.context_window_usage(),
files_changed: tool_usage.apply_file_diff_stats.files_changed,
lines_added: tool_usage.apply_file_diff_stats.lines_added,
lines_removed: tool_usage.apply_file_diff_stats.lines_removed,
commands_executed,
current_context_tokens: conversation.current_context_tokens(),
estimated_cost_cents,
total_cache_read_tokens,
total_cache_write_tokens,
total_input_tokens,
};
let timing_info = TimingInfo {
time_to_first_token_ms,
total_agent_response_time_ms,
wall_to_wall_response_time_ms,
};
// View to hold the usage footer.
let usage_view = ctx.add_view(|_| {
ConversationUsageView::new(
conversation_usage_info,
DisplayMode::Footer,
Some(timing_info),
MouseStateHandle::default(),
)
});
self.usage_footer_view_ids
.insert(source_ai_block_view_id, usage_view.id());
let agent_view_conversation_id = self
.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id();
let item = RichContentItem::new(None, usage_view.id(), agent_view_conversation_id, false);
let mut model = self.model.lock();
let inserted = model.block_list_mut().insert_rich_content_after_item(
RemovableBlocklistItem::RichContent(source_ai_block_view_id),
item,
);
drop(model);
if inserted {
self.rich_content_views.push(
RichContent::new(usage_view, agent_view_conversation_id)
.with_metadata(RichContentMetadata::UsageFooter),
);
} else {
// Fallback: append usage block to the end of the blocklist
self.insert_rich_content(
None,
usage_view,
Some(RichContentMetadata::UsageFooter),
RichContentInsertionPosition::Append {
insert_below_long_running_block: true,
},
ctx,
);
}
ctx.notify();
}
fn toggle_usage_footer(&mut self, ctx: &mut ViewContext<Self>) {
let conversation_id = self
.agent_view_controller
.as_ref(ctx)
.agent_view_state()
.active_conversation_id();
let Some(conversation_id) = conversation_id else {
return;
};
let last_ai_block_handle = self
.rich_content_views
.iter()
.rev()
.find_map(|rich_content| {
let ai_metadata = rich_content.ai_block_metadata()?;
(ai_metadata.conversation_id == conversation_id)
.then(|| ai_metadata.ai_block_handle.clone())
});
if let Some(ai_block_handle) = last_ai_block_handle {
ai_block_handle.update(ctx, |block, ctx| {
block.handle_action(&AIBlockAction::ToggleIsUsageFooterExpanded, ctx);
});
}
fn toggle_usage_footer(&mut self, _ctx: &mut ViewContext<Self>) {
// Usage footer has been replaced by the persistent status bar in the input area.
}
fn toggle_context_view(&mut self, ctx: &mut ViewContext<Self>) {
@@ -13785,15 +13614,6 @@ impl TerminalView {
true
});
// Close any open usage footers on blocks being removed to prevent them becoming orphaned
for (view_id, handle) in &blocks_to_remove {
if self.usage_footer_view_ids.contains_key(view_id) {
handle.update(ctx, |block, ctx| {
block.handle_action(&AIBlockAction::ToggleIsUsageFooterExpanded, ctx);
});
}
}
blocks_to_remove.into_iter().for_each(|(view_id, handle)| {
handle.update(ctx, |block, ctx| {
block.cleanup_block(ctx);
@@ -19039,12 +18859,6 @@ impl TerminalView {
AIBlockEvent::CopiedEmptyText => {
self.copy(ctx);
}
AIBlockEvent::UsageFooterToggled {
conversation_id,
is_expanded,
} => {
self.handle_usage_footer_toggled(block.id(), *conversation_id, *is_expanded, ctx);
}
AIBlockEvent::OpenSettings => {
ctx.emit(Event::OpenSettings(SettingsSection::WarpAgent));
}