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:
co-authored by
Claude Opus 4.6
parent
259244863b
commit
9e11c91a6e
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2292,6 +2292,10 @@ fn convert_usage_metadata(
|
||||
credits_spent_for_last_block: None,
|
||||
token_usage: vec![],
|
||||
tool_usage_metadata: Default::default(),
|
||||
total_cache_read_tokens: 0,
|
||||
total_cache_write_tokens: 0,
|
||||
total_cache_miss_tokens: 0,
|
||||
total_cost_cents: 0.0,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -171,6 +171,10 @@ impl From<&ConversationUsageMetadata> for persistence::model::ConversationUsageM
|
||||
credits_spent_for_last_block: None,
|
||||
token_usage: convert_token_usage(&gql.warp_token_usage, &gql.byok_token_usage),
|
||||
tool_usage_metadata: (&gql.tool_usage_metadata).into(),
|
||||
total_cache_read_tokens: 0,
|
||||
total_cache_write_tokens: 0,
|
||||
total_cache_miss_tokens: 0,
|
||||
total_cost_cents: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1304,6 +1304,14 @@ pub struct ConversationUsageMetadata {
|
||||
pub token_usage: Vec<ModelTokenUsage>,
|
||||
#[serde(default)]
|
||||
pub tool_usage_metadata: ToolUsageMetadata,
|
||||
#[serde(default)]
|
||||
pub total_cache_read_tokens: u32,
|
||||
#[serde(default)]
|
||||
pub total_cache_write_tokens: u32,
|
||||
#[serde(default)]
|
||||
pub total_cache_miss_tokens: u32,
|
||||
#[serde(default)]
|
||||
pub total_cost_cents: f32,
|
||||
}
|
||||
|
||||
impl ConversationUsageMetadata {
|
||||
|
||||
Reference in New Issue
Block a user