From f278e53b7e0ad91fb08e71e300ca7284cc11ac6e Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Thu, 21 May 2026 14:30:04 -0500 Subject: [PATCH] v1.5.0: Inline subagent panels, Bedrock compaction fixes, context window debug view New features: - Inline subagent panels with expand/collapse and click-to-toggle - /context slash command to inspect bedrock_message_history - Child-to-parent question routing with auto-answer for subagents - Subagent token usage and cost merging into parent conversation - Randomized session-colored user avatar silhouettes Bug fixes: - Bedrock: remove orphaned tool_results after compaction - Bedrock: self-healing exchange lookup for out-of-order streaming - Bedrock: append continuation prompt when conversation ends with assistant - Duration sanity check rejects epoch-time artifacts from session restore - Cache hit rate calculation uses actual total_input_tokens - Hide "Time to first token" when value is zero Improvements: - Demote verbose bedrock-debug logs to debug/trace levels - Bedrock tool usage counting falls back to action counting - Remove logout menu item from workspace menu Co-Authored-By: Claude Opus 4.6 (1M context) --- app/Cargo.toml | 2 +- app/src/ai/agent/conversation.rs | 168 +++++++++++++--- app/src/ai/agent/task.rs | 21 +- app/src/ai/bedrock/request_translator.rs | 147 ++++++++++++++ app/src/ai/bedrock/response_translator.rs | 16 +- .../action_model/execute/ask_user_question.rs | 58 ++++++ .../agent_view/subagent_inline_panel.rs | 40 ++-- app/src/ai/blocklist/block.rs | 34 ++++ .../ai/blocklist/block/view_impl/common.rs | 40 ++-- .../block/view_impl/orchestration.rs | 58 +++--- app/src/ai/blocklist/controller.rs | 68 ++++++- .../blocklist/controller/response_stream.rs | 4 +- app/src/ai/blocklist/orchestration_events.rs | 51 +++++ .../ai/blocklist/usage/context_window_view.rs | 179 ++++++++++++++++++ .../usage/conversation_usage_view.rs | 34 ++-- app/src/ai/blocklist/usage/mod.rs | 1 + .../static_commands/commands.rs | 10 + app/src/terminal/input/slash_commands/mod.rs | 14 ++ app/src/terminal/view.rs | 71 ++++++- app/src/terminal/view/action.rs | 3 + app/src/workspace/view.rs | 7 - app/src/workspaces/gql_convert.rs | 1 + 22 files changed, 914 insertions(+), 113 deletions(-) create mode 100644 app/src/ai/blocklist/usage/context_window_view.rs diff --git a/app/Cargo.toml b/app/Cargo.toml index d80350ca..ee9daebf 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -5,7 +5,7 @@ description = "Galaxy - AI-powered terminal" edition = "2021" autobins = false name = "galaxy" -version = "1.3.0" +version = "1.5.0" publish.workspace = true license.workspace = true diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index eb2bf865..39cedf39 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -714,7 +714,14 @@ impl AIConversation { })?; let duration = finish_time.signed_duration_since(start_time); - Some(duration.num_milliseconds()) + let ms = duration.num_milliseconds(); + + // Sanity check: reject durations that are clearly wrong (> 24 hours + // likely means start_time defaulted to epoch during session restore). + if ms < 0 || ms > 86_400_000 { + return None; + } + Some(ms) } pub fn token_usage(&self) -> &[ModelTokenUsage] { @@ -1363,6 +1370,45 @@ impl AIConversation { }) } + /// Counts all tool-call actions across the entire conversation. + pub fn count_all_actions(&self) -> usize { + self.all_exchanges() + .into_iter() + .flat_map(|exchange| { + exchange + .output_status + .output() + .into_iter() + .map(|output| output.get().actions().count()) + }) + .sum() + } + + /// Counts RequestCommandOutput actions across the entire conversation. + pub fn count_command_actions(&self) -> usize { + self.all_exchanges() + .into_iter() + .flat_map(|exchange| { + exchange + .output_status + .output() + .into_iter() + .map(|output| { + output + .get() + .actions() + .filter(|a| { + matches!( + a.action, + super::AIAgentActionType::RequestCommandOutput { .. } + ) + }) + .count() + }) + }) + .sum() + } + pub fn contains_action(&self, action_id: &AIAgentActionId) -> bool { self.task_store.tasks().any(|task| { task.exchanges() @@ -2606,52 +2652,83 @@ impl AIConversation { mask: Some(mask), }) => { let task_id = TaskId::new(task_id); - log::info!( - "[bedrock-debug] AppendToMessageContent: task_id={:?}, message_id={:?}", + log::debug!( + "[bedrock] AppendToMessageContent: task_id={:?}, message_id={:?}", task_id, message.id ); - let exchange_id = match self.added_exchanges_by_response.get(response_stream_id) { + + // Self-healing exchange lookup: if no exchange exists yet for this + // task (e.g. the initial AddMessagesToTask was dropped or arrived + // out of order), lazily create one so streaming doesn't break. + let (exchange_id, created_exchange) = match self + .added_exchanges_by_response + .get(response_stream_id) + { Some(exchanges) => { - log::info!( - "[bedrock-debug] AppendToMessageContent: found {} exchanges for stream", - exchanges.len() - ); - for ex in exchanges.iter() { - log::info!( - "[bedrock-debug] exchange: task_id={:?}, exchange_id={:?}", - ex.task_id, - ex.exchange_id - ); - } match exchanges.iter().find_map(|new_exchange| { (new_exchange.task_id == task_id).then_some(new_exchange.exchange_id) }) { - Some(id) => id, + Some(id) => (id, false), None => { - log::error!( - "[bedrock-debug] AppendToMessageContent: ExchangeNotFound - no exchange with matching task_id" + log::warn!( + "[bedrock] AppendToMessageContent: no exchange for task_id={:?}, creating one", + task_id ); - return Err(UpdateConversationError::ExchangeNotFound); + // Remove target task first to avoid borrow conflicts, + // same pattern as AddMessagesToTask (line 2462). + let mut task = self + .task_store + .remove(&task_id) + .ok_or(UpdateConversationError::TaskNotFound)?; + let existing_exchange_id = exchanges.last().exchange_id; + let existing_exchange = self + .get_task(&exchanges.last().task_id) + .ok_or(UpdateConversationError::TaskNotFound)? + .exchange(existing_exchange_id) + .ok_or(UpdateConversationError::ExchangeNotFound)?; + let new_exchange_id = task.append_new_exchange(existing_exchange); + self.task_store.insert(task); + (new_exchange_id, true) } } } None => { log::error!( - "[bedrock-debug] AppendToMessageContent: NoPendingRequest - no exchanges for this stream_id" + "[bedrock] AppendToMessageContent: NoPendingRequest - no exchanges for this stream_id" ); return Err(UpdateConversationError::NoPendingRequest); } }; - log::info!( - "[bedrock-debug] AppendToMessageContent: found exchange_id={:?}", - exchange_id + // Register the newly created exchange so subsequent appends find it + if created_exchange { + self.added_exchanges_by_response + .get_mut(response_stream_id) + .ok_or(UpdateConversationError::NoPendingRequest)? + .push(AddedExchange { + task_id: task_id.clone(), + exchange_id, + }); + let is_hidden = self.hidden_exchanges.contains(&exchange_id); + ctx.emit(BlocklistAIHistoryEvent::AppendedExchange { + response_stream_id: Some(response_stream_id.clone()), + exchange_id, + task_id: task_id.clone(), + terminal_view_id, + conversation_id: self.id, + is_hidden, + }); + } + + log::debug!( + "[bedrock] AppendToMessageContent: exchange_id={:?} (created={})", + exchange_id, + created_exchange ); let current_todo_list = self.todo_lists.last().cloned(); let current_comment_state = self.code_review.as_ref().cloned(); - // Update the message and get the updated todos op, if any. let todos_op = match self.task_store.modify_task(&task_id, |task| { task.append_to_message_content( message, @@ -2663,25 +2740,21 @@ impl AIConversation { .map(|msg| msg.todos_op().cloned()) }) { Some(result) => match result { - Ok(todos_op) => { - log::info!("[bedrock-debug] AppendToMessageContent: append succeeded"); - todos_op - } + Ok(todos_op) => todos_op, Err(e) => { log::error!( - "[bedrock-debug] AppendToMessageContent: append_to_message_content failed: {e:?}" + "[bedrock] AppendToMessageContent failed: {e:?}" ); return Err(e.into()); } }, None => { log::error!( - "[bedrock-debug] AppendToMessageContent: TaskNotFound in task_store" + "[bedrock] AppendToMessageContent: TaskNotFound in task_store" ); return Err(UpdateConversationError::TaskNotFound); } }; - // Update todo list if needed if let Some(todos_op) = todos_op { update_todo_list_from_todo_op(&mut self.todo_lists, todos_op); ctx.emit(BlocklistAIHistoryEvent::UpdatedTodoList { terminal_view_id }); @@ -3149,6 +3222,39 @@ impl AIConversation { .sum() } + /// Merges a child subagent's token usage and cost into this conversation's totals. + /// Does NOT affect context_window_tokens (parent's context is independent). + pub fn merge_child_usage_raw( + &mut self, + child_token_usage: &HashMap, + child_request_cost: RequestCost, + ) { + self.total_request_cost += child_request_cost; + + for (model_id, child_usage) in child_token_usage { + let entry = self + .total_token_usage_by_model + .entry(model_id.clone()) + .or_insert_with(|| TokenUsage { + model_id: model_id.clone(), + total_input: 0, + output: 0, + input_cache_read: 0, + input_cache_write: 0, + cost_in_cents: 0.0, + }); + entry.total_input += child_usage.total_input; + entry.output += child_usage.output; + entry.input_cache_read += child_usage.input_cache_read; + entry.input_cache_write += child_usage.input_cache_write; + entry.cost_in_cents += child_usage.cost_in_cents; + } + } + + pub fn total_token_usage_by_model(&self) -> &HashMap { + &self.total_token_usage_by_model + } + pub fn total_input_tokens(&self) -> u32 { self.total_token_usage_by_model .values() diff --git a/app/src/ai/agent/task.rs b/app/src/ai/agent/task.rs index 1e473232..6c8f4f5f 100644 --- a/app/src/ai/agent/task.rs +++ b/app/src/ai/agent/task.rs @@ -758,8 +758,25 @@ impl Task { .enumerate() .find(|(_, m)| message.id == m.id) else { - log::error!("Message not found for append client action."); - return Err(UpdateTaskError::MessageNotFound); + // Self-healing: if the message doesn't exist yet (e.g. the initial + // AddMessagesToTask was dropped or arrived out of order), treat this + // append as an implicit add so streaming doesn't break. + log::warn!( + "[bedrock] append_to_message_content: message_id={} not found, treating as implicit add", + message.id + ); + self.add_messages( + vec![message], + exchange_id, + current_todo_list, + current_comments, + false, + )?; + return self + .try_get_source()? + .messages + .last() + .ok_or(UpdateTaskError::MessageNotFound); }; let updated_message = FieldMaskOperation::append(&api::MESSAGE_DESCRIPTOR, existing_message, &message, mask) diff --git a/app/src/ai/bedrock/request_translator.rs b/app/src/ai/bedrock/request_translator.rs index afcbe1a0..93f47d19 100644 --- a/app/src/ai/bedrock/request_translator.rs +++ b/app/src/ai/bedrock/request_translator.rs @@ -430,8 +430,155 @@ fn extract_input_messages(request: &api::Request) -> Vec { /// Call this on the combined (history + new input) messages before /// sending to `build_converse_request`. pub fn sanitize_messages_for_bedrock(messages: &mut Vec) { + remove_orphaned_tool_results(messages); ensure_starts_with_user_message(messages); ensure_tool_results_paired(messages); + ensure_ends_with_user_message(messages); +} + +/// Bedrock requires the conversation to end with a user message. +/// If the last message is an assistant message (e.g. after compaction), +/// append a continuation prompt. +fn ensure_ends_with_user_message(messages: &mut Vec) { + if messages.last().is_some_and(|m| m.role == MessageRole::Assistant) { + log::info!("[bedrock] Appending continuation prompt (conversation ended with assistant message)"); + messages.push(ConversationMessage { + role: MessageRole::User, + content: MessageContent::Text("Continue.".to_string()), + }); + } +} + +/// Removes tool_result content that references tool_use IDs not present in any +/// preceding assistant message. This happens after compaction when the history +/// is replaced with a summary but the next request still carries tool_results +/// from the old (now-discarded) exchanges. +fn remove_orphaned_tool_results(messages: &mut Vec) { + use std::collections::HashSet; + + // Collect all tool_use IDs from assistant messages. + let mut valid_tool_use_ids = HashSet::new(); + for msg in messages.iter() { + if msg.role == MessageRole::Assistant { + collect_tool_use_ids_into(&msg.content, &mut valid_tool_use_ids); + } + } + + if valid_tool_use_ids.is_empty() { + // No tool_use in history — remove ALL tool_results from user messages. + let before_count = messages.len(); + messages.retain(|msg| { + if msg.role != MessageRole::User { + return true; + } + !is_pure_tool_result(&msg.content) + }); + + // Also strip tool_result parts from MultiPart user messages. + for msg in messages.iter_mut() { + if msg.role != MessageRole::User { + continue; + } + strip_tool_result_parts(&mut msg.content); + } + + if messages.len() != before_count { + log::info!( + "[bedrock] Removed {} orphaned tool_result message(s) (no tool_use in history)", + before_count - messages.len() + ); + } + return; + } + + // Remove tool_results whose IDs aren't in valid_tool_use_ids. + for msg in messages.iter_mut() { + if msg.role != MessageRole::User { + continue; + } + strip_orphaned_tool_result_parts(&mut msg.content, &valid_tool_use_ids); + } + + // Remove messages that became empty after stripping. + messages.retain(|msg| !is_empty_content(&msg.content)); +} + +fn is_pure_tool_result(content: &MessageContent) -> bool { + matches!(content, MessageContent::ToolResult { .. }) +} + +fn strip_tool_result_parts(content: &mut MessageContent) { + if let MessageContent::MultiPart(parts) = content { + parts.retain(|p| !matches!(p, ContentPart::ToolResult { .. })); + if parts.len() == 1 { + let part = parts.remove(0); + *content = match part { + ContentPart::Text(t) => MessageContent::Text(t), + ContentPart::ToolUse { tool_use_id, name, input } => { + MessageContent::ToolUse { tool_use_id, name, input } + } + ContentPart::ToolResult { tool_use_id, content: c, is_error } => { + MessageContent::ToolResult { tool_use_id, content: c, is_error } + } + }; + } + } +} + +fn strip_orphaned_tool_result_parts( + content: &mut MessageContent, + valid_ids: &std::collections::HashSet, +) { + match content { + MessageContent::ToolResult { tool_use_id, .. } => { + if !valid_ids.contains(tool_use_id) { + *content = MessageContent::Text(String::new()); + } + } + MessageContent::MultiPart(parts) => { + parts.retain(|p| match p { + ContentPart::ToolResult { tool_use_id, .. } => valid_ids.contains(tool_use_id), + _ => true, + }); + if parts.len() == 1 { + let part = parts.remove(0); + *content = match part { + ContentPart::Text(t) => MessageContent::Text(t), + ContentPart::ToolUse { tool_use_id, name, input } => { + MessageContent::ToolUse { tool_use_id, name, input } + } + ContentPart::ToolResult { tool_use_id, content: c, is_error } => { + MessageContent::ToolResult { tool_use_id, content: c, is_error } + } + }; + } + } + _ => {} + } +} + +fn is_empty_content(content: &MessageContent) -> bool { + match content { + MessageContent::Text(t) => t.is_empty(), + MessageContent::MultiPart(parts) => parts.is_empty(), + _ => false, + } +} + +fn collect_tool_use_ids_into(content: &MessageContent, ids: &mut std::collections::HashSet) { + match content { + MessageContent::ToolUse { tool_use_id, .. } => { + ids.insert(tool_use_id.clone()); + } + MessageContent::MultiPart(parts) => { + for p in parts { + if let ContentPart::ToolUse { tool_use_id, .. } = p { + ids.insert(tool_use_id.clone()); + } + } + } + _ => {} + } } fn ensure_starts_with_user_message(messages: &mut Vec) { diff --git a/app/src/ai/bedrock/response_translator.rs b/app/src/ai/bedrock/response_translator.rs index 4fb717f9..1dd9bd6b 100644 --- a/app/src/ai/bedrock/response_translator.rs +++ b/app/src/ai/bedrock/response_translator.rs @@ -124,10 +124,10 @@ pub fn bedrock_stream_to_response_events( event_count += 1; match event { StreamEvent::MessageStart(_) => { - log::info!("[bedrock-debug] Event #{event_count}: MessageStart"); + log::debug!("[bedrock] Event #{event_count}: MessageStart"); } StreamEvent::ContentBlockStart(block_start) => { - log::info!("[bedrock-debug] Event #{event_count}: ContentBlockStart"); + log::debug!("[bedrock] Event #{event_count}: ContentBlockStart"); if let Some(start) = block_start.start() { match start { ContentBlockStart::ToolUse(tool_start) => { @@ -166,11 +166,11 @@ pub fn bedrock_stream_to_response_events( } } StreamEvent::ContentBlockDelta(delta) => { - log::info!("[bedrock-debug] Event #{event_count}: ContentBlockDelta"); + log::trace!("[bedrock] Event #{event_count}: ContentBlockDelta"); if let Some(d) = delta.delta() { match d { ContentBlockDelta::Text(text) => { - log::info!("[bedrock-debug] Event #{event_count}: TextDelta ({} chars): {:?}", text.len(), &text[..text.len().min(80)]); + log::debug!("[bedrock] TextDelta ({} chars)", text.len()); history_text.push_str(text); if text_flushed { let msg_id = current_text_message_id.as_ref().unwrap(); @@ -182,6 +182,10 @@ pub fn bedrock_stream_to_response_events( yield Ok(append); } else { buffered_text.push_str(text); + // Buffer a few initial deltas so the first + // AddMessagesToTask carries enough content for + // the exchange to be fully registered before + // subsequent AppendToMessageContent events arrive. if buffered_text.len() >= 1 { let msg_id = Uuid::new_v4().to_string(); current_text_message_id = Some(msg_id.clone()); @@ -209,7 +213,7 @@ pub fn bedrock_stream_to_response_events( } } StreamEvent::ContentBlockStop(_) => { - log::info!("[bedrock-debug] Event #{event_count}: ContentBlockStop (tool_use_id={:?})", if current_tool_use_id.is_empty() { "none" } else { ¤t_tool_use_id }); + log::debug!("[bedrock] Event #{event_count}: ContentBlockStop (tool_use_id={:?})", if current_tool_use_id.is_empty() { "none" } else { ¤t_tool_use_id }); if !current_tool_use_id.is_empty() { // Skip suggest_next_prompt — its executor hangs forever // waiting for UI interaction that doesn't exist in the @@ -382,7 +386,7 @@ pub fn bedrock_stream_to_response_events( &model_id, ); log::info!( - "[bedrock] Stream finished: model={model_id}, input_tokens={input_tokens}, output_tokens={output_tokens}, cache_read={cache_read_input_tokens}, cache_write={cache_write_input_tokens}, cost_cents={cost:.4}" + "[bedrock] Stream finished: {event_count} events, model={model_id}, input_tokens={input_tokens}, output_tokens={output_tokens}, cache_read={cache_read_input_tokens}, cache_write={cache_write_input_tokens}, cost_cents={cost:.4}" ); // Build and store the assistant message into bedrock_messages_sent diff --git a/app/src/ai/blocklist/action_model/execute/ask_user_question.rs b/app/src/ai/blocklist/action_model/execute/ask_user_question.rs index c03df992..aeea72ea 100644 --- a/app/src/ai/blocklist/action_model/execute/ask_user_question.rs +++ b/app/src/ai/blocklist/action_model/execute/ask_user_question.rs @@ -1,4 +1,6 @@ use crate::ai::agent::{AIAgentActionResultType, AIAgentActionType}; +use crate::ai::blocklist::orchestration_events::OrchestrationEventService; +use crate::ai::blocklist::BlocklistAIHistoryModel; use crate::ai::blocklist::BlocklistAIPermissions; use ai::agent::action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult}; use futures::{future::BoxFuture, FutureExt}; @@ -51,6 +53,52 @@ impl AskUserQuestionExecutor { } }; + // For child agent conversations, route the question to the parent + // for silent auto-answer instead of presenting UI to the user. + if let Some(parent_conversation_id) = self.parent_conversation_id(input.conversation_id, ctx) + { + let question_text = questions + .iter() + .map(|q| q.question.clone()) + .collect::>() + .join("\n"); + let options: Vec = questions + .iter() + .filter_map(|q| q.multiple_choice_options()) + .flatten() + .map(|o| o.label.clone()) + .collect(); + + OrchestrationEventService::handle(ctx).update(ctx, |service, ctx| { + service.route_subagent_question_to_parent( + input.conversation_id, + parent_conversation_id, + question_text, + options, + 0, + ctx, + ); + }); + + // Wait for the parent's answer to arrive via the same channel + let receiver = self.result_rx.1.clone(); + return ActionExecution::new_async( + async move { receiver.recv().await }, + |result, _ctx| match result { + Ok(AskUserQuestionDecision::Completed(answers)) => { + AIAgentActionResultType::AskUserQuestion( + AskUserQuestionResult::Success { answers }, + ) + } + Ok(AskUserQuestionDecision::Cancelled) | Err(_) => { + AIAgentActionResultType::AskUserQuestion( + AskUserQuestionResult::Cancelled, + ) + } + }, + ); + } + if self.should_autoexecute(input, ctx) { let question_ids = questions .iter() @@ -80,6 +128,16 @@ impl AskUserQuestionExecutor { ) } + fn parent_conversation_id( + &self, + conversation_id: crate::ai::agent::conversation::AIConversationId, + ctx: &ModelContext, + ) -> Option { + let history_model = BlocklistAIHistoryModel::as_ref(ctx); + let conversation = history_model.conversation(&conversation_id)?; + conversation.parent_conversation_id() + } + pub(super) fn preprocess_action( &mut self, _action: PreprocessActionInput, diff --git a/app/src/ai/blocklist/agent_view/subagent_inline_panel.rs b/app/src/ai/blocklist/agent_view/subagent_inline_panel.rs index 3eabcfaa..5f75dfcc 100644 --- a/app/src/ai/blocklist/agent_view/subagent_inline_panel.rs +++ b/app/src/ai/blocklist/agent_view/subagent_inline_panel.rs @@ -3,9 +3,8 @@ //! Shows a collapsible panel with the subagent's status, a mini-transcript of //! recent messages, and controls to expand to full view or cancel. -use galaxy_core::ui::theme::Fill; use galaxyui::elements::{ - ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Empty, Flex, + ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Empty, Flex, Hoverable, MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text, }; use galaxyui::{AppContext, SingletonEntity}; @@ -13,6 +12,8 @@ use pathfinder_color::ColorU; use warp_multi_agent_api as api; use crate::ai::agent::conversation::{AIConversationId, ConversationStatus}; +use crate::ai::agent::AIAgentActionId; +use crate::ai::blocklist::block::AIBlockAction; use crate::ai::blocklist::inline_action::inline_action_header::{ ICON_MARGIN, INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING, }; @@ -51,6 +52,7 @@ impl SubagentPanelState { /// Renders the inline subagent panel for a child conversation. pub fn render_subagent_inline_panel( state: &SubagentPanelState, + action_id: &AIAgentActionId, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); @@ -71,14 +73,28 @@ pub fn render_subagent_inline_panel( let mut column = Flex::column().with_cross_axis_alignment(CrossAxisAlignment::Stretch); - // Header — always visible - column.add_child(render_panel_header( - &agent_name, - &status, - state, - panel_bg, - app, - )); + // Header — always visible, click to toggle expand/collapse + let header_mouse_state = state.header_mouse_state.clone(); + let toggle_action_id = action_id.clone(); + let header_status = status.clone(); + let header_expanded = state.is_expanded; + column.add_child( + Hoverable::new(header_mouse_state, move |_mouse_state| { + render_panel_header( + &agent_name, + &header_status, + header_expanded, + panel_bg, + app, + ) + }) + .on_click(move |ctx, _, _| { + ctx.dispatch_typed_action(AIBlockAction::ToggleSubagentPanel { + action_id: toggle_action_id.clone(), + }); + }) + .finish(), + ); // Body (mini-transcript) — only when expanded if state.is_expanded { @@ -106,7 +122,7 @@ pub fn render_subagent_inline_panel( fn render_panel_header( agent_name: &str, status: &ConversationStatus, - state: &SubagentPanelState, + is_expanded: bool, _background: ColorU, app: &AppContext, ) -> Box { @@ -168,7 +184,7 @@ fn render_panel_header( // Right: collapse/expand chevron let mut right_side = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center); - let chevron_icon = if state.is_expanded { + let chevron_icon = if is_expanded { Icon::ChevronDown } else { Icon::ChevronRight diff --git a/app/src/ai/blocklist/block.rs b/app/src/ai/blocklist/block.rs index 3c4459eb..2c91871b 100644 --- a/app/src/ai/blocklist/block.rs +++ b/app/src/ai/blocklist/block.rs @@ -398,6 +398,7 @@ pub(super) struct AIBlockStateHandles { /// A given citation should only appear once per block. footer_citation_chip_handles: HashMap, orchestration_navigation_card_handles: HashMap, + pub(super) subagent_panel_states: HashMap, references_section_collapsible_handle: MouseStateHandle, @@ -1369,6 +1370,10 @@ impl AIBlock { me.run_secret_redaction_on_user_query(me.client_ids.conversation_id, ctx); me.spawn_link_detection(ctx); + // Create summarization view immediately if this block has a SummarizeConversation input, + // so the "Summarizing..." UI appears before the API response starts streaming. + me.maybe_create_summarization_view_from_input(ctx); + if me.model.status(ctx).is_streaming() { me.model .on_updated_output(Box::new(Self::on_output_status_update), ctx); @@ -4237,6 +4242,27 @@ impl AIBlock { }); } + // Create subagent panel state for finished StartAgent actions + if let Some(AIActionStatus::Finished(result)) = + action_model.as_ref(ctx).get_action_status(action_id) + { + if let AIAgentActionResultType::StartAgent( + crate::ai::agent::StartAgentResult::Success { agent_id, .. }, + ) = &result.result + { + if !me.state_handles.subagent_panel_states.contains_key(action_id) { + if let Some(conversation_id) = + crate::ai::blocklist::agent_view::orchestration_conversation_links::conversation_id_for_agent_id(agent_id, ctx) + { + me.state_handles.subagent_panel_states.insert( + action_id.clone(), + super::agent_view::subagent_inline_panel::SubagentPanelState::new(conversation_id), + ); + } + } + } + } + let action_statuses = me .requested_action_ids .iter() @@ -5825,6 +5851,9 @@ pub enum AIBlockAction { OpenCommentInGitHub { url: String, }, + ToggleSubagentPanel { + action_id: AIAgentActionId, + }, } impl TypedActionView for AIBlock { @@ -6474,6 +6503,11 @@ impl TypedActionView for AIBlock { initial_index, }); } + AIBlockAction::ToggleSubagentPanel { action_id } => { + if let Some(state) = self.state_handles.subagent_panel_states.get_mut(action_id) { + state.is_expanded = !state.is_expanded; + } + } } ctx.notify(); } diff --git a/app/src/ai/blocklist/block/view_impl/common.rs b/app/src/ai/blocklist/block/view_impl/common.rs index 61f3bc1e..790e4626 100644 --- a/app/src/ai/blocklist/block/view_impl/common.rs +++ b/app/src/ai/blocklist/block/view_impl/common.rs @@ -3403,30 +3403,46 @@ pub struct FindContext<'a> { pub state: &'a FindState, } -/// Renders a user avatar with profile image or display name. +/// A palette of colors for the user avatar silhouette, randomly selected per session. +const USER_AVATAR_PALETTE: &[ColorU] = &[ + ColorU { r: 99, g: 179, b: 237, a: 255 }, // blue + ColorU { r: 129, g: 230, b: 217, a: 255 }, // teal + ColorU { r: 183, g: 148, b: 244, a: 255 }, // purple + ColorU { r: 252, g: 165, b: 165, a: 255 }, // red/coral + ColorU { r: 251, g: 191, b: 36, a: 255 }, // amber + ColorU { r: 110, g: 231, b: 183, a: 255 }, // green + ColorU { r: 249, g: 168, b: 212, a: 255 }, // pink + ColorU { r: 253, g: 186, b: 116, a: 255 }, // orange +]; + +fn session_avatar_color() -> ColorU { + use std::sync::OnceLock; + use rand::Rng; + static COLOR: OnceLock = OnceLock::new(); + *COLOR.get_or_init(|| { + let idx = rand::thread_rng().gen_range(0..USER_AVATAR_PALETTE.len()); + USER_AVATAR_PALETTE[idx] + }) +} + +/// Renders a user avatar as a silhouette icon with a session-random color. pub fn render_user_avatar( - user_display_name: &str, - profile_image_path: Option<&String>, + _user_display_name: &str, + _profile_image_path: Option<&String>, avatar_color: Option, app: &AppContext, ) -> Box { let appearance = Appearance::as_ref(app); - let theme = appearance.theme(); - let background = avatar_color.unwrap_or_else(|| blended_colors::accent(theme).into()); + let background = avatar_color.unwrap_or_else(session_avatar_color); let avatar = Avatar::new( - profile_image_path - .map(|url| AvatarContent::Image { - url: url.to_owned(), - display_name: user_display_name.to_owned(), - }) - .unwrap_or(AvatarContent::DisplayName(user_display_name.to_owned())), + AvatarContent::Icon(Icon::User), UiComponentStyles { width: Some(icon_size(app)), height: Some(icon_size(app)), font_family_id: Some(appearance.ui_font_family()), font_size: Some(appearance.monospace_font_size() - 2.), background: Some(background.into()), - font_color: Some(blended_colors::text_main(theme, background)), + font_color: Some(ColorU::white()), border_radius: Some(CornerRadius::with_all(Radius::Percentage(50.))), ..Default::default() }, diff --git a/app/src/ai/blocklist/block/view_impl/orchestration.rs b/app/src/ai/blocklist/block/view_impl/orchestration.rs index 05b6ea9d..7dea59e0 100644 --- a/app/src/ai/blocklist/block/view_impl/orchestration.rs +++ b/app/src/ai/blocklist/block/view_impl/orchestration.rs @@ -432,28 +432,42 @@ pub(super) fn render_start_agent( } } if let Some(card_data) = child_conversation_card_data { - let navigation_card_handle = props - .state_handles - .orchestration_navigation_card_handles - .get(action_id) - .cloned() - .unwrap_or_else(|| { - log::error!( - "Missing orchestration navigation card handle for StartAgent action {:?}", - action_id - ); - MouseStateHandle::default() - }); - let status_icon = card_data.status.status_icon_and_color(theme); - column.add_child(render_conversation_navigation_card_row( - &card_data.agent_name, - Some(&card_data.title), - Some(status_icon), - card_data.conversation_id, - navigation_card_handle, - true, - app, - )); + // Render inline subagent panel instead of navigation card + if let Some(panel_state) = + props.state_handles.subagent_panel_states.get(action_id) + { + column.add_child( + crate::ai::blocklist::agent_view::subagent_inline_panel::render_subagent_inline_panel( + panel_state, + action_id, + app, + ), + ); + } else { + // Fallback: render the navigation card if no panel state exists yet + let navigation_card_handle = props + .state_handles + .orchestration_navigation_card_handles + .get(action_id) + .cloned() + .unwrap_or_else(|| { + log::error!( + "Missing orchestration navigation card handle for StartAgent action {:?}", + action_id + ); + MouseStateHandle::default() + }); + let status_icon = card_data.status.status_icon_and_color(theme); + column.add_child(render_conversation_navigation_card_row( + &card_data.agent_name, + Some(&card_data.title), + Some(status_icon), + card_data.conversation_id, + navigation_card_handle, + true, + app, + )); + } } return column diff --git a/app/src/ai/blocklist/controller.rs b/app/src/ai/blocklist/controller.rs index 153a6f5f..85d35477 100644 --- a/app/src/ai/blocklist/controller.rs +++ b/app/src/ai/blocklist/controller.rs @@ -78,7 +78,9 @@ use std::sync::Arc; use std::time::Duration; use warp_multi_agent_api::{message, Task, ToolType}; -use super::orchestration_events::{OrchestrationEventService, OrchestrationEventServiceEvent}; +use super::orchestration_events::{ + OrchestrationEventService, OrchestrationEventServiceEvent, PendingEventDetail, +}; use galaxyui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity}; #[derive(Debug, Clone)] @@ -1507,6 +1509,9 @@ impl BlocklistAIController { return; } + // Handle local subagent events before standard orchestration events. + self.handle_subagent_events(conversation_id, ctx); + if self .in_flight_response_streams .has_active_stream_for_conversation(conversation_id, ctx) @@ -1555,6 +1560,67 @@ impl BlocklistAIController { } } + /// Processes local subagent events (questions, answers, summaries) for a conversation. + fn handle_subagent_events( + &mut self, + conversation_id: AIConversationId, + ctx: &mut ModelContext, + ) { + let events = OrchestrationEventService::handle(ctx).update(ctx, |svc, _ctx| { + svc.drain_subagent_events(&conversation_id) + }); + + for event in events { + match event.detail { + PendingEventDetail::SubagentQuestion { + source_conversation_id, + question_text, + options, + .. + } => { + // Auto-answer: pick the first option, or echo the question text + // as a default answer. In a future version, this could invoke the + // parent LLM for a contextual answer. + let answer = options.first().cloned().unwrap_or_else(|| { + format!("Proceed with: {}", question_text) + }); + + OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| { + svc.route_answer_to_subagent(source_conversation_id, answer, ctx); + }); + } + PendingEventDetail::SubagentAnswer { + answer_text, .. + } => { + // This fires on the child's controller — complete its pending question. + self.complete_ask_user_question_with_answer(answer_text, ctx); + } + PendingEventDetail::SubagentCompletionSummary { .. } => { + // Summary is consumed by the inline panel renderer directly. + // No controller action needed. + } + _ => {} + } + } + } + + /// Completes this controller's pending AskUserQuestion with the answer from the parent. + fn complete_ask_user_question_with_answer( + &self, + answer_text: String, + ctx: &mut ModelContext, + ) { + use ai::agent::action_result::AskUserQuestionAnswerItem; + + let executor = self.action_model.as_ref(ctx).ask_user_question_executor(ctx); + let answer_item = AskUserQuestionAnswerItem::Answered { + question_id: String::new(), + selected_options: vec![answer_text.clone()], + other_text: answer_text, + }; + executor.as_ref(ctx).complete(vec![answer_item]); + } + pub fn resume_conversation( &mut self, conversation_id: AIConversationId, diff --git a/app/src/ai/blocklist/controller/response_stream.rs b/app/src/ai/blocklist/controller/response_stream.rs index f4934d02..6f16989d 100644 --- a/app/src/ai/blocklist/controller/response_stream.rs +++ b/app/src/ai/blocklist/controller/response_stream.rs @@ -259,8 +259,8 @@ impl ResponseStream { let event_type_name = match &response_event.r#type { Some(warp_multi_agent_api::response_event::Type::Init(_)) => "Init", Some(warp_multi_agent_api::response_event::Type::ClientActions(a)) => { - log::info!( - "[bedrock-debug] ResponseStream received ClientActions with {} actions", + log::debug!( + "[bedrock] ResponseStream received ClientActions with {} actions", a.actions.len() ); "ClientActions" diff --git a/app/src/ai/blocklist/orchestration_events.rs b/app/src/ai/blocklist/orchestration_events.rs index 27ec5acc..718510dc 100644 --- a/app/src/ai/blocklist/orchestration_events.rs +++ b/app/src/ai/blocklist/orchestration_events.rs @@ -552,6 +552,57 @@ impl OrchestrationEventService { LifecycleEventType::Idle, result, ); + + // Emit completion summary to parent and merge costs + let (parent_id, summary) = { + let history_model = BlocklistAIHistoryModel::as_ref(ctx); + let parent_id = history_model + .conversation(&conversation_id) + .and_then(|c| c.parent_conversation_id()); + let summary = parent_id.and_then(|_| { + let conv = history_model.conversation(&conversation_id)?; + let messages = conv.all_linearized_messages(); + messages.iter().rev().find_map(|msg| { + let message_content = msg.message.as_ref()?; + match message_content { + warp_multi_agent_api::message::Message::AgentOutput(output) + if !output.text.is_empty() => + { + let text = if output.text.len() > 500 { + format!("{}...", &output.text[..497]) + } else { + output.text.clone() + }; + Some(text) + } + _ => None, + } + }) + }); + (parent_id, summary) + }; + if let Some(parent_id) = parent_id { + // Merge child's token usage and costs into parent + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _ctx| { + let child_usage: Option<(HashMap, crate::ai::agent::RequestCost)> = history_model + .conversation(&conversation_id) + .map(|c| (c.total_token_usage_by_model().clone(), c.total_request_cost())); + if let Some((token_usage, request_cost)) = child_usage { + if let Some(parent) = history_model.conversation_mut(&parent_id) { + parent.merge_child_usage_raw(&token_usage, request_cost); + } + } + }); + + if let Some(summary_text) = summary { + self.route_subagent_completion_summary( + conversation_id, + parent_id, + summary_text, + ctx, + ); + } + } } (Some(ConversationStatus::InProgress), ConversationStatus::Error) => { let result = self.dispatch_lifecycle_event( diff --git a/app/src/ai/blocklist/usage/context_window_view.rs b/app/src/ai/blocklist/usage/context_window_view.rs new file mode 100644 index 00000000..459492f6 --- /dev/null +++ b/app/src/ai/blocklist/usage/context_window_view.rs @@ -0,0 +1,179 @@ +use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole}; +use crate::appearance::Appearance; +use crate::ui_components::blended_colors; +use galaxyui::{ + elements::{ + Container, CornerRadius, CrossAxisAlignment, Flex, ParentElement, Radius, Text, + }, + AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext, +}; + +pub struct ContextWindowView { + messages: Vec, +} + +impl ContextWindowView { + pub fn new(messages: Vec) -> Self { + Self { messages } + } +} + +impl View for ContextWindowView { + fn ui_name() -> &'static str { + "ContextWindowView" + } + + fn render(&self, app: &AppContext) -> Box { + let appearance = Appearance::as_ref(app); + let theme = appearance.theme(); + let font_size = appearance.ui_font_size(); + let text_color = blended_colors::text_main(theme, theme.surface_2()); + let label_color = blended_colors::text_sub(theme, theme.surface_2()); + + let mut column = Flex::column() + .with_cross_axis_alignment(CrossAxisAlignment::Stretch) + .with_spacing(6.0); + + // Header + let total_chars: usize = self + .messages + .iter() + .map(|m| match &m.content { + MessageContent::Text(t) => t.len(), + MessageContent::ToolUse { input, .. } => input.to_string().len(), + MessageContent::ToolResult { content, .. } => content.len(), + MessageContent::MultiPart(parts) => parts + .iter() + .map(|p| match p { + ContentPart::Text(t) => t.len(), + ContentPart::ToolUse { input, .. } => input.to_string().len(), + ContentPart::ToolResult { content, .. } => content.len(), + }) + .sum(), + }) + .sum(); + let estimated_tokens = total_chars / 4; + + let header_text = format!( + "Context Window: {} messages, ~{} tokens est.", + self.messages.len(), + format_tokens(estimated_tokens as u32), + ); + column = column.with_child( + Text::new( + header_text, + appearance.ui_font_family(), + font_size + 1.0, + ) + .with_color(label_color) + .finish(), + ); + + // Messages — show full content, no truncation. + // The parent blocklist handles scrolling. + for (i, msg) in self.messages.iter().enumerate() { + let role_str = match msg.role { + MessageRole::User => "USER", + MessageRole::Assistant => "ASST", + }; + + // Role header + let header_line = format!("--- [{}] Message #{} ---", role_str, i); + column = column.with_child( + Text::new(header_line, appearance.ui_font_family(), font_size) + .with_color(label_color) + .soft_wrap(true) + .finish(), + ); + + // Full content + let content_text = match &msg.content { + MessageContent::Text(t) => t.clone(), + MessageContent::ToolUse { name, tool_use_id, input } => { + format!( + "[ToolUse] name={}, id={}\ninput={}", + name, tool_use_id, input + ) + } + MessageContent::ToolResult { + tool_use_id, + content, + is_error, + } => { + format!( + "[ToolResult] id={}, error={}\n{}", + tool_use_id, is_error, content + ) + } + MessageContent::MultiPart(parts) => { + let mut out = String::new(); + for (pi, p) in parts.iter().enumerate() { + match p { + ContentPart::Text(t) => { + out.push_str(&format!("[Part {} Text] {}\n", pi, t)); + } + ContentPart::ToolUse { name, tool_use_id, input } => { + out.push_str(&format!( + "[Part {} ToolUse] name={}, id={}, input={}\n", + pi, name, tool_use_id, input + )); + } + ContentPart::ToolResult { tool_use_id, content, is_error } => { + out.push_str(&format!( + "[Part {} ToolResult] id={}, error={}\n{}\n", + pi, tool_use_id, is_error, content + )); + } + } + } + out + } + }; + + column = column.with_child( + Text::new(content_text, appearance.ui_font_family(), font_size) + .with_color(text_color) + .soft_wrap(true) + .finish(), + ); + } + + if self.messages.is_empty() { + column = column.with_child( + Text::new( + "(empty - no messages in bedrock history)".to_string(), + appearance.ui_font_family(), + font_size, + ) + .with_color(label_color) + .finish(), + ); + } + + Container::new(column.finish()) + .with_uniform_padding(12.0) + .with_background(theme.surface_2()) + .with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.0))) + .finish() + } +} + +impl Entity for ContextWindowView { + type Event = (); +} + +impl TypedActionView for ContextWindowView { + type Action = (); + + fn handle_action(&mut self, _action: &Self::Action, _ctx: &mut ViewContext) {} +} + +fn format_tokens(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}") + } +} diff --git a/app/src/ai/blocklist/usage/conversation_usage_view.rs b/app/src/ai/blocklist/usage/conversation_usage_view.rs index 1cb76b58..4f59bc06 100644 --- a/app/src/ai/blocklist/usage/conversation_usage_view.rs +++ b/app/src/ai/blocklist/usage/conversation_usage_view.rs @@ -41,6 +41,8 @@ pub struct ConversationUsageInfo { pub total_cache_read_tokens: u32, /// Cumulative cache write tokens (session total). pub total_cache_write_tokens: u32, + /// Cumulative total input tokens across all requests (session total). + pub total_input_tokens: u32, } /// Timing information for the last set of agent responses @@ -278,15 +280,15 @@ impl ConversationUsageView { )); } - // Cache hit rate - let cache_miss = self.usage_info.current_context_tokens - .saturating_sub(self.usage_info.total_cache_read_tokens); - let total_input = self.usage_info.total_cache_read_tokens + cache_miss; - if total_input > 0 { - let hit_rate = (self.usage_info.total_cache_read_tokens as f32 / total_input as f32) * 100.0; + // Cache hit rate: proportion of total input tokens served from cache + let total_input = self.usage_info.total_input_tokens; + if total_input > 0 && self.usage_info.total_cache_read_tokens > 0 { + let hit_rate = (self.usage_info.total_cache_read_tokens as f32 + / total_input as f32) + * 100.0; labels.push(render_label_text("Cache hit rate", appearance)); values.push(render_value_text( - format!("{:.0}%", hit_rate), + format!("{:.0}%", hit_rate.min(100.0)), appearance, )); } @@ -410,14 +412,16 @@ impl ConversationUsageView { )); values.push(render_section_header("".to_string(), appearance)); - labels.push(render_label_text("Time to first token", appearance)); - values.push(render_value_text( - format!( - "{:.1} seconds", - timing.time_to_first_token_ms as f64 / 1000.0 - ), - appearance, - )); + if timing.time_to_first_token_ms > 0 { + labels.push(render_label_text("Time to first token", appearance)); + values.push(render_value_text( + format!( + "{:.1} seconds", + timing.time_to_first_token_ms as f64 / 1000.0 + ), + appearance, + )); + } labels.push(render_label_text("Total agent response time", appearance)); values.push(render_value_text( diff --git a/app/src/ai/blocklist/usage/mod.rs b/app/src/ai/blocklist/usage/mod.rs index fc39479c..f7720e88 100644 --- a/app/src/ai/blocklist/usage/mod.rs +++ b/app/src/ai/blocklist/usage/mod.rs @@ -2,6 +2,7 @@ use galaxy_core::ui::theme::{Fill, GalaxyTheme}; use galaxy_core::ui::Icon; use galaxyui::Element; +pub mod context_window_view; pub mod conversation_usage_view; pub fn icon_for_context_window_usage(context_window_usage: f32) -> Icon { diff --git a/app/src/search/slash_command_menu/static_commands/commands.rs b/app/src/search/slash_command_menu/static_commands/commands.rs index ddf751de..86c4c53c 100644 --- a/app/src/search/slash_command_menu/static_commands/commands.rs +++ b/app/src/search/slash_command_menu/static_commands/commands.rs @@ -387,6 +387,15 @@ pub const COST: StaticCommand = StaticCommand { argument: None, }; +pub const CONTEXT: StaticCommand = StaticCommand { + name: "/context", + description: "Show current context window contents (debug)", + icon_path: "bundled/svg/bar-chart-04.svg", + availability: Availability::AGENT_VIEW.union(Availability::AI_ENABLED), + auto_enter_ai_mode: false, + argument: None, +}; + pub const CONVERSATIONS: StaticCommand = StaticCommand { name: "/conversations", description: "Open conversation history", @@ -506,6 +515,7 @@ fn all_commands() -> Vec { ADD_MCP, ADD_PROMPT.clone(), ADD_RULE, + CONTEXT, COST, FEEDBACK.clone(), INDEX, diff --git a/app/src/terminal/input/slash_commands/mod.rs b/app/src/terminal/input/slash_commands/mod.rs index 2ca539c6..a375e98c 100644 --- a/app/src/terminal/input/slash_commands/mod.rs +++ b/app/src/terminal/input/slash_commands/mod.rs @@ -698,6 +698,20 @@ impl Input { ctx.dispatch_typed_action(&TerminalAction::ToggleUsageFooter); } } + _context if command.name == commands::CONTEXT.name => { + let history = BlocklistAIHistoryModel::handle(ctx); + let conversation = history + .as_ref(ctx) + .active_conversation(self.terminal_view_id); + if conversation.is_none() { + show_error_toast( + "Cannot show context: no active conversation".to_owned(), + ctx, + ); + } else { + ctx.dispatch_typed_action(&TerminalAction::ToggleContextView); + } + } _fork if command.name == commands::FORK.name => { let Some(conversation_id) = self .ai_context_model diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 67bd03b4..41ce37d7 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -2586,6 +2586,9 @@ pub struct TerminalView { /// Cached view ids for usage footers keyed by the AI block view id that owns them. usage_footer_view_ids: HashMap, + /// View ID of the context window debug view, if visible. + context_view_id: Option, + // Whether the block onboarding view is active or not. block_onboarding_active: bool, @@ -4089,6 +4092,7 @@ impl TerminalView { active_filter_editor_block_index: None, rich_content_views: Vec::new(), usage_footer_view_ids: Default::default(), + context_view_id: None, block_onboarding_active: false, onboarding_agentic_suggestions_block: None, onboarding_prompt_block: None, @@ -5621,6 +5625,18 @@ impl TerminalView { }; 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(); @@ -5631,20 +5647,22 @@ impl TerminalView { 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_usage.total_tool_calls(), + 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: tool_usage.run_command_stats.commands_executed, + 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 { @@ -5729,6 +5747,51 @@ impl TerminalView { } } + fn toggle_context_view(&mut self, ctx: &mut ViewContext) { + use crate::ai::blocklist::usage::context_window_view::ContextWindowView; + + // If already showing, remove it + if let Some(view_id) = self.context_view_id.take() { + let mut model = self.model.lock(); + model.block_list_mut().remove_rich_content(view_id); + drop(model); + self.rich_content_views.retain(|rc| rc.view_id() != view_id); + ctx.notify(); + return; + } + + // Get the active conversation's bedrock_message_history + 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 messages = BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&conversation_id) + .map(|conv| conv.bedrock_message_history().to_vec()) + .unwrap_or_default(); + + let context_view = ctx.add_view(|_| ContextWindowView::new(messages)); + let view_id = context_view.id(); + self.context_view_id = Some(view_id); + + self.insert_rich_content( + None, + context_view, + None, + RichContentInsertionPosition::Append { + insert_below_long_running_block: true, + }, + ctx, + ); + ctx.notify(); + } + /// Returns true if the window is wide enough to auto-open side panels. pub fn can_auto_open_panel(&self) -> bool { self.size_info.pane_width_px().as_f32() > MINIMUM_WIDTH_TO_AUTO_OPEN_PANE @@ -24407,6 +24470,7 @@ impl TypedActionView for TerminalView { | AwsCliNotInstalledBanner(_) | ExecuteRewindFromInlineMenu { .. } | ToggleUsageFooter + | ToggleContextView | RevealChildAgent { .. } | OpenCLIAgentRichInput | ToggleSessionRecording => Empty, @@ -25428,6 +25492,9 @@ impl TypedActionView for TerminalView { ToggleUsageFooter => { self.toggle_usage_footer(ctx); } + ToggleContextView => { + self.toggle_context_view(ctx); + } RevealChildAgent { conversation_id } => { ctx.emit(Event::RevealChildAgent { conversation_id: *conversation_id, diff --git a/app/src/terminal/view/action.rs b/app/src/terminal/view/action.rs index 518f8886..9f4e949c 100644 --- a/app/src/terminal/view/action.rs +++ b/app/src/terminal/view/action.rs @@ -422,6 +422,8 @@ pub enum TerminalAction { AwsCliNotInstalledBanner(AwsCliNotInstalledBannerAction), /// Toggle the usage footer on the last AI block in the active conversation. ToggleUsageFooter, + /// Toggle the context window debug view showing bedrock_message_history. + ToggleContextView, /// Reveal a hidden child agent pane from the orchestrator status card. RevealChildAgent { conversation_id: AIConversationId, @@ -702,6 +704,7 @@ impl fmt::Debug for TerminalAction { AwsBedrockLoginBanner(action) => write!(f, "AwsBedrockLoginBanner({action:?})"), AwsCliNotInstalledBanner(action) => write!(f, "AwsCliNotInstalledBanner({action:?})"), ToggleUsageFooter => write!(f, "ToggleUsageFooter"), + ToggleContextView => write!(f, "ToggleContextView"), RevealChildAgent { .. } => write!(f, "RevealChildAgent"), ToggleSessionRecording => write!(f, "ToggleSessionRecording"), OpenCLIAgentRichInput => write!(f, "OpenCLIAgentRichInput"), diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index f554152a..1c2f66f1 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -8326,13 +8326,6 @@ impl Workspace { .into_item(), ); - if !self.auth_state.is_anonymous_or_logged_out() { - items.push( - MenuItemFields::new("Log out") - .with_on_select_action(WorkspaceAction::LogOut) - .into_item(), - ); - } items } diff --git a/app/src/workspaces/gql_convert.rs b/app/src/workspaces/gql_convert.rs index da71b155..13a1f950 100644 --- a/app/src/workspaces/gql_convert.rs +++ b/app/src/workspaces/gql_convert.rs @@ -265,6 +265,7 @@ impl From<&gql_usage::ConversationUsage> for ConversationUsageInfo { estimated_cost_cents: 0.0, total_cache_read_tokens: 0, total_cache_write_tokens: 0, + total_input_tokens: 0, } } }