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
+29 -1
View File
@@ -470,6 +470,28 @@ impl AIConversation {
let task_store = TaskStore::from_tasks(tasks_by_id, root_task_id);
let restored_token_usage = {
let mut map = HashMap::new();
let cache_read = conversation_usage_metadata.total_cache_read_tokens;
let cache_write = conversation_usage_metadata.total_cache_write_tokens;
let cache_miss = conversation_usage_metadata.total_cache_miss_tokens;
let cost = conversation_usage_metadata.total_cost_cents;
if cache_read > 0 || cache_write > 0 || cache_miss > 0 || cost > 0.0 {
map.insert(
"restored".to_string(),
TokenUsage {
model_id: "restored".to_string(),
total_input: cache_miss,
output: 0,
input_cache_read: cache_read,
input_cache_write: cache_write,
cost_in_cents: cost,
},
);
}
map
};
Ok(Self {
id,
is_viewing_shared_session: false,
@@ -493,7 +515,7 @@ impl AIConversation {
reverted_action_ids,
dismissed_suggestion_ids: Default::default(),
total_request_cost: RequestCost::new(0.),
total_token_usage_by_model: Default::default(),
total_token_usage_by_model: restored_token_usage,
last_block_token_usage_by_model: Default::default(),
optimistic_cli_subagent_subtask_id: None,
fallback_display_title: None,
@@ -1735,6 +1757,12 @@ impl AIConversation {
last_block_entry.cost_in_cents += usage.cost_in_cents;
}
// Sync accumulated cache/cost totals into persisted metadata
self.conversation_usage_metadata.total_cache_read_tokens = self.total_cache_read_tokens();
self.conversation_usage_metadata.total_cache_write_tokens = self.total_cache_write_tokens();
self.conversation_usage_metadata.total_cache_miss_tokens = self.cache_miss_tokens();
self.conversation_usage_metadata.total_cost_cents = self.total_cost_cents();
if let Some(request_cost) = request_cost {
let credits_spent_for_last_block = self
.conversation_usage_metadata
-21
View File
@@ -391,8 +391,6 @@ pub(super) struct AIBlockStateHandles {
/// Mouse state handle for the fork conversation button
fork_conversation_handle: MouseStateHandle,
/// Mouse state handle for the usage button
usage_button_handle: MouseStateHandle,
/// Mouse state handles per citation.
/// A given citation should only appear once per block.
@@ -926,9 +924,6 @@ pub struct AIBlock {
/// When set, CopyCommand will copy this specific command instead of all commands.
last_right_clicked_command: Option<String>,
/// Whether the usage summary footer is expanded.
is_usage_footer_expanded: bool,
/// Controller for reading/modifying `AgentView` state for this terminal pane (e.g. if there is
/// an active agent view or not, which affects whether or not this block should be hidden).
///
@@ -1354,7 +1349,6 @@ impl AIBlock {
rewind_button,
view_screenshot_buttons: Default::default(),
last_right_clicked_command: None,
is_usage_footer_expanded: false,
agent_view_controller,
aws_bedrock_credentials_error_view: None,
imported_comments: Default::default(),
@@ -5591,12 +5585,6 @@ pub enum AIBlockEvent {
/// commands and requested actions have been executed or cancelled).
Finished,
/// Emitted when we want to show or hide the usage footer.
UsageFooterToggled {
conversation_id: AIConversationId,
is_expanded: bool,
},
/// Emitted when the AI block requires user confirmation to execute.
ActionBlockedOnUserConfirmation,
@@ -5826,8 +5814,6 @@ pub enum AIBlockAction {
CopyDebugId(String),
/// Open Warp feedback documentation
OpenFeedbackDocs,
/// Toggle the usage summary footer expansion state
ToggleIsUsageFooterExpanded,
CommentExpanded {
id: CommentId,
},
@@ -6027,13 +6013,6 @@ impl TypedActionView for AIBlock {
AIBlockAction::ToggleReferencesSection => {
self.is_references_section_open = !self.is_references_section_open;
}
AIBlockAction::ToggleIsUsageFooterExpanded => {
self.is_usage_footer_expanded = !self.is_usage_footer_expanded;
ctx.emit(AIBlockEvent::UsageFooterToggled {
conversation_id: self.client_ids.conversation_id,
is_expanded: self.is_usage_footer_expanded,
});
}
AIBlockAction::CommentExpanded { id } => {
let Some(comment) = self.comment_states.get_mut(id) else {
return;
-1
View File
@@ -1088,7 +1088,6 @@ impl View for AIBlock {
has_accepted_edits,
current_todo_list: self.current_todo_list(app),
finish_reason: self.finish_reason.as_ref(),
is_usage_footer_expanded: self.is_usage_footer_expanded,
shared_session_status: &shared_session_status,
terminal_view_id: self.terminal_view_id,
is_conversation_transcript_viewer,
+2 -156
View File
@@ -41,12 +41,9 @@ use galaxy_core::ui::theme::color::internal_colors;
#[allow(unused_imports)]
use galaxy_util::path::{common_path, CleanPathResult};
use galaxyui::elements::new_scrollable::SingleAxisConfig;
use galaxyui::elements::{
ChildAnchor, NewScrollable, OffsetPositioning, ParentAnchor, ParentOffsetBounds, Stack,
};
use galaxyui::elements::NewScrollable;
use galaxyui::EntityId;
use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use ui_components::{button, Component as _, Options as _};
use crate::ai::blocklist::block::{
@@ -130,7 +127,7 @@ use super::{
use galaxyui::{
elements::{
Align, Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Empty, Expanded, Fill, Flex, FormattedTextElement, Hoverable, MainAxisAlignment,
Expanded, Fill, Flex, FormattedTextElement, Hoverable, MainAxisAlignment,
MainAxisSize, ParentElement, Radius, Shrinkable, Text, Wrap,
},
keymap::Keystroke,
@@ -186,7 +183,6 @@ pub(crate) struct Props<'a> {
pub(super) current_todo_list: Option<&'a AIAgentTodoList>,
pub(super) has_accepted_edits: bool,
pub(super) finish_reason: Option<&'a FinishReason>,
pub(super) is_usage_footer_expanded: bool,
pub(super) shared_session_status: &'a SharedSessionStatus,
pub(super) terminal_view_id: EntityId,
pub(super) is_conversation_transcript_viewer: bool,
@@ -3182,7 +3178,6 @@ fn render_response_footer(props: Props, app: &AppContext) -> Option<Box<dyn Elem
flex.add_child(fork_button);
}
flex.add_child(render_usage_button(props, app));
// Review changes button.
if props.has_accepted_edits && !props.shared_session_status.is_viewer() {
@@ -3214,155 +3209,6 @@ fn render_response_footer(props: Props, app: &AppContext) -> Option<Box<dyn Elem
Some(flex.finish().with_content_item_spacing().finish())
}
/// Renders the usage button that, on click, will expand & collapse the usage summary footer.
fn render_usage_button(props: Props, app: &AppContext) -> Box<dyn Element> {
let Some(conversation) = props.model.conversation(app) else {
return Empty::new().finish();
};
let has_any_usage = conversation.total_tokens() > 0
|| conversation.total_cost_cents() > 0.0;
if !has_any_usage {
return Empty::new().finish();
}
let appearance = Appearance::as_ref(app);
let ui_builder = appearance.ui_builder().clone();
let expansion_icon = if props.is_usage_footer_expanded {
Icon::ChevronDown
} else {
Icon::ChevronRight
};
let context_usage = conversation.context_window_usage();
let current_context = conversation.current_context_tokens();
let cache_read = conversation.total_cache_read_tokens();
let cache_write = conversation.total_cache_write_tokens();
let total_input = conversation.total_input_tokens();
let cost_cents = conversation.total_cost_cents();
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 cache_total_ops = cache_read + cache_write + total_input;
let cache_hit_pct = if cache_total_ops > 0 {
(cache_read as f64 / cache_total_ops as f64) * 100.0
} else {
0.0
};
let usage_text = format!(
"Context: {:.1}% ({} / {}) | Cache Hit: {:.1}% (R: {}, W: {}, M: {}) | Cost: ${:.2}",
context_pct,
format_token_count(current_context),
format_token_count(max_context),
cache_hit_pct,
format_token_count(cache_read),
format_token_count(cache_write),
format_token_count(total_input),
cost_cents / 100.0,
);
let icon_size = icon_size(app);
let button_row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Center)
.with_main_axis_size(MainAxisSize::Min)
.with_child(
Container::new(
Text::new_inline(
usage_text,
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(
appearance
.theme()
.sub_text_color(appearance.theme().background())
.into(),
)
.with_selectable(false)
.finish(),
)
.with_padding_top(2.)
.with_margin_left(4.)
.finish(),
)
.with_child(
Container::new(
ConstrainedBox::new(
expansion_icon
.to_galaxyui_icon(
appearance
.theme()
.sub_text_color(appearance.theme().background()),
)
.finish(),
)
.with_width(icon_size)
.with_height(icon_size)
.finish(),
)
.with_margin_top(1.)
.finish(),
);
Hoverable::new(
props.state_handles.usage_button_handle.clone(),
|mouse_state| {
let mut content = Container::new(button_row.finish());
if mouse_state.is_hovered() || mouse_state.is_clicked() {
let background = if mouse_state.is_clicked() {
appearance.theme().background()
} else {
blended_colors::neutral_4(appearance.theme()).into()
};
content = content
.with_background(background)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(4.)));
let mut stack = Stack::new().with_child(content.finish());
let tooltip = ui_builder
.tool_tip("Show usage details".to_string())
.build()
.finish();
stack.add_positioned_overlay_child(
tooltip,
OffsetPositioning::offset_from_parent(
vec2f(0., 8.),
ParentOffsetBounds::WindowByPosition,
ParentAnchor::BottomMiddle,
ChildAnchor::TopMiddle,
),
);
stack.finish()
} else {
content.finish()
}
},
)
.on_click(|ctx, _, _| {
ctx.dispatch_typed_action(AIBlockAction::ToggleIsUsageFooterExpanded);
})
.with_cursor(Cursor::PointingHand)
.finish()
}
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}")
}
}
pub fn action_icon<V: View>(
action_id: &AIAgentActionId,