v1.4.0: Auto-compact streaming, Bedrock summarization support, subagent orchestration, and Galaxy rebrand continuation

Major features:
- Auto-compact: triggers conversation summarization when context window >= 85%,
  compacts Bedrock message history to a summary pair, and tracks live context tokens
- Bedrock summarization: plumbs `is_summarization` flag through translator/client/response
  pipeline, handles SummarizeConversation input type, and marks `summarized` in metadata
- Session restore: rebuilds bedrock_message_history from persisted task messages via
  newly-public `convert_proto_message`, preventing empty history on reconnect
- Subagent orchestration: adds SubagentQuestion/Answer/CompletionSummary event types,
  parent-child question routing with depth limits, retry counting, and drain methods
- Summarization UI: inline SummarizationView in AI blocks with progress/finished states

Refactors:
- Rename WarpTheme → GalaxyTheme across ~100 files (rebrand continuation)
- Rename warp_home_config_dir → galaxy_home_config_dir and related path functions
- Predefined rules: replace "System Defined Rule #N" with descriptive names
  (e.g. "Correctness Over Speed", "Never Guess") and add lookup helpers
- Usage view: replace cumulative input/output token display with live context tokens,
  cache hit rate calculation, and separate cache read/write stats
- Telemetry: remove verbose doc comments, simplify trait definitions
- Facts view: simplify delete permission check (always allow local deletion)
- Remove warp_managed_paths_watcher.rs (dead code)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Ward
2026-05-21 11:59:37 -05:00
co-authored by Claude Opus 4.6
parent eaa2ddc75e
commit 6f54e2cb30
229 changed files with 2506 additions and 2634 deletions
+3
View File
@@ -139,6 +139,8 @@ pub struct RequestParams {
/// can store them back into the conversation for the next request cycle.
pub bedrock_messages_sent:
std::sync::Arc<std::sync::Mutex<Vec<crate::ai::bedrock::convert::ConversationMessage>>>,
/// Whether this request is a conversation summarization/compaction.
pub is_summarization: bool,
}
pub type Event = Result<warp_multi_agent_api::ResponseEvent, Arc<AIApiError>>;
@@ -327,6 +329,7 @@ impl RequestParams {
agent_name: None,
bedrock_message_history: Vec::new(),
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
is_summarization: false,
}
}
}
+1
View File
@@ -154,6 +154,7 @@ pub async fn generate_multi_agent_output(
root_task_id: params.root_task_id.clone(),
bedrock_message_history: params.bedrock_message_history.clone(),
bedrock_messages_sent: params.bedrock_messages_sent.clone(),
is_summarization: params.is_summarization,
};
match translator::execute(translator_request, &mut request).await {
+1
View File
@@ -42,6 +42,7 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool
agent_name: None,
bedrock_message_history: Vec::new(),
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
is_summarization: false,
}
}
+76 -3
View File
@@ -30,7 +30,7 @@ use galaxy_core::features::FeatureFlag;
use galaxy_core::send_telemetry_from_ctx;
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxy_core::ui::theme::WarpTheme;
use galaxy_core::ui::theme::GalaxyTheme;
use galaxyui::color::ColorU;
use galaxyui::{EntityId, ModelContext, SingletonEntity};
use uuid::Uuid;
@@ -235,6 +235,19 @@ pub struct AIConversation {
/// tool calls, and tool results sent to/received from Bedrock across all
/// request cycles. This is the source of truth for what Bedrock sees.
bedrock_message_history: Vec<crate::ai::bedrock::convert::ConversationMessage>,
/// Live context token count from the most recent Bedrock response.
/// This is the actual input_tokens reported by Bedrock — represents the current
/// context window size, NOT a cumulative total.
current_context_tokens: u32,
/// Guards against repeated auto-compact triggers within the same high-usage window.
/// Set to true when auto-compact fires; reset when summarization completes.
has_pending_auto_compact: bool,
/// Number of times this child agent conversation has been automatically
/// restarted after a transient error. Capped at MAX_SUBAGENT_RETRIES.
subagent_retry_count: u8,
}
pub(crate) fn artifact_from_fork_proto(
@@ -287,6 +300,9 @@ impl AIConversation {
is_remote_child: false,
last_event_sequence: None,
bedrock_message_history: Vec::new(),
current_context_tokens: 0,
has_pending_auto_compact: false,
subagent_retry_count: 0,
}
}
@@ -314,6 +330,20 @@ impl AIConversation {
.cmp(depths.get(a.as_str()).unwrap_or(&0))
});
// Collect all task messages for rebuilding bedrock_message_history on restore.
// We must do this before consuming the tasks into exchanges.
let all_task_messages: Vec<&api::Message> = api_tasks_by_id
.values()
.flat_map(|task| task.messages.iter())
.collect();
let bedrock_message_history: Vec<crate::ai::bedrock::convert::ConversationMessage> =
all_task_messages
.iter()
.filter_map(|msg| {
crate::ai::bedrock::request_translator::convert_proto_message(msg)
})
.collect();
let mut api_tasks_and_exchanges_by_id: HashMap<_, _> = api_tasks_by_id
.into_iter()
.map(|(id, task)| {
@@ -469,7 +499,10 @@ impl AIConversation {
parent_conversation_id,
is_remote_child: false,
last_event_sequence,
bedrock_message_history: Vec::new(),
bedrock_message_history,
current_context_tokens: 0,
has_pending_auto_compact: false,
subagent_retry_count: 0,
})
}
@@ -521,6 +554,26 @@ impl AIConversation {
self.conversation_usage_metadata.context_window_usage
}
pub fn set_context_window_usage(&mut self, value: f32) {
self.conversation_usage_metadata.context_window_usage = value;
}
pub fn current_context_tokens(&self) -> u32 {
self.current_context_tokens
}
pub fn set_current_context_tokens(&mut self, tokens: u32) {
self.current_context_tokens = tokens;
}
pub fn has_pending_auto_compact(&self) -> bool {
self.has_pending_auto_compact
}
pub fn set_has_pending_auto_compact(&mut self, value: bool) {
self.has_pending_auto_compact = value;
}
pub fn credits_spent(&self) -> f32 {
(self.conversation_usage_metadata.credits_spent * 10.0).round() / 10.0
}
@@ -859,6 +912,14 @@ impl AIConversation {
self.is_remote_child = true;
}
pub fn subagent_retry_count(&self) -> u8 {
self.subagent_retry_count
}
pub fn increment_subagent_retry_count(&mut self) {
self.subagent_retry_count = self.subagent_retry_count.saturating_add(1);
}
/// Returns a flat list of linearized messages across all tasks, interpolating subtask messages
/// in between subagent tool calls and results, effectively corresponding to the order in which
/// the messages were created and added to the conversation.
@@ -1566,6 +1627,17 @@ impl AIConversation {
if was_user_initiated_request {
self.last_block_token_usage_by_model.clear();
}
// Update live context token count from this response's input tokens.
// This represents the actual current context window size (not cumulative).
let live_input: u32 = token_usage
.iter()
.map(|u| u.total_input + u.input_cache_read + u.input_cache_write)
.sum();
if live_input > 0 {
self.current_context_tokens = live_input;
}
for usage in token_usage.into_iter() {
let entry = self
.total_token_usage_by_model
@@ -1666,6 +1738,7 @@ impl AIConversation {
// so we only update the summarized flag if it's going from false to true.
if usage_metadata.summarized && !self.conversation_usage_metadata.was_summarized {
self.conversation_usage_metadata.was_summarized = usage_metadata.summarized;
self.has_pending_auto_compact = false;
}
}
Ok(())
@@ -3844,7 +3917,7 @@ impl ConversationStatus {
}
}
pub fn status_icon_and_color(&self, theme: &WarpTheme) -> (Icon, ColorU) {
pub fn status_icon_and_color(&self, theme: &GalaxyTheme) -> (Icon, ColorU) {
match self {
ConversationStatus::InProgress => (Icon::ClockLoader, theme.ansi_fg_magenta()),
ConversationStatus::Success => (Icon::Check, theme.ansi_fg_green()),
+2 -2
View File
@@ -29,7 +29,7 @@ use galaxy_cli::agent::Harness;
use galaxy_core::execution_mode::AppExecutionMode;
use galaxy_core::features::FeatureFlag;
use galaxy_core::report_error;
use galaxy_core::ui::theme::{color::internal_colors, WarpTheme};
use galaxy_core::ui::theme::{color::internal_colors, GalaxyTheme};
use galaxyui::color::ColorU;
use galaxyui::r#async::Timer;
use galaxyui::windowing::{StateEvent, WindowManager};
@@ -334,7 +334,7 @@ impl AgentRunDisplayStatus {
)
}
pub fn status_icon_and_color(&self, theme: &WarpTheme) -> (Icon, ColorU) {
pub fn status_icon_and_color(&self, theme: &GalaxyTheme) -> (Icon, ColorU) {
match self {
AgentRunDisplayStatus::TaskQueued
| AgentRunDisplayStatus::TaskPending
@@ -1,7 +1,7 @@
use std::sync::Arc;
use galaxy_core::ui::icons::Icon;
use galaxy_core::ui::theme::{Fill, WarpTheme};
use galaxy_core::ui::theme::{Fill, GalaxyTheme};
use galaxyui::clipboard::ClipboardContent;
use galaxyui::elements::{
ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, DispatchEventResult,
@@ -331,7 +331,7 @@ fn render_timestamp_with_dot(item: &NotificationItem, appearance: &Appearance) -
fn render_expand_chevron(
expanded: bool,
on_click: OnExpandClick,
theme: &WarpTheme,
theme: &GalaxyTheme,
) -> Box<dyn Element> {
let icon = if expanded {
Icon::ChevronDown
@@ -411,7 +411,7 @@ const NOTIFICATION_AVATAR_SIZING: IconWithStatusSizing = IconWithStatusSizing {
fn render_agent_avatar(
agent: NotificationSourceAgent,
category: NotificationCategory,
theme: &WarpTheme,
theme: &GalaxyTheme,
) -> Box<dyn Element> {
let status = notification_category_to_conversation_status(category);
let variant = match agent {
+4 -4
View File
@@ -134,14 +134,14 @@ where
}
}
/// Refresh Warp Drive before executing an operation.
/// Refresh Galaxy Drive before executing an operation.
pub fn refresh_warp_drive(
ctx: &AppContext,
) -> impl Future<Output = anyhow::Result<()>> + Send + 'static {
UpdateManager::as_ref(ctx)
.initial_load_complete()
.with_timeout(WARP_DRIVE_SYNC_TIMEOUT)
.map_err(|_| anyhow::anyhow!("Timed out waiting for Warp Drive to sync"))
.map_err(|_| anyhow::anyhow!("Timed out waiting for Galaxy Drive to sync"))
}
/// Fetch the conversation's server metadata and validate that its harness matches the caller's
@@ -213,7 +213,7 @@ pub enum EnvironmentChoice {
impl EnvironmentChoice {
/// Resolve the environment to use when creating an agent integration.
/// Warp Drive *must* have been synced first.
/// Galaxy Drive *must* have been synced first.
pub fn resolve_for_create(
args: EnvironmentCreateArgs,
ctx: &AppContext,
@@ -279,7 +279,7 @@ Without an environment, the agent will not be able to access private repositorie
/// Resolve the environment to use when updating an agent integration. If the user did not
/// request any changes to the environment, this returns `Ok(None)`.
/// Warp Drive *must* have been synced first.
/// Galaxy Drive *must* have been synced first.
pub fn resolve_for_update(
args: EnvironmentUpdateArgs,
ctx: &AppContext,
+2 -2
View File
@@ -357,7 +357,7 @@ pub enum AgentDriverError {
#[error("Agent profile \"{0}\" not found")]
ProfileError(String),
#[error(
"Failed to authenticate with server - please log in via 'oz login', provide an API key via '--api-key <key>', or set the WARP_API_KEY environment variable"
"Failed to authenticate with server - please log in via 'oz login', provide an API key via '--api-key <key>', or set the GALAXY_API_KEY environment variable"
)]
NotLoggedIn,
#[error("Saved prompt not found for id {0}")]
@@ -369,7 +369,7 @@ pub enum AgentDriverError {
#[source]
error: terminal::ShareSessionError,
},
#[error("Error syncing Warp Drive")]
#[error("Error syncing Galaxy Drive")]
WarpDriveSyncFailed,
#[error("Requested environment not found: {0}")]
EnvironmentNotFound(String),
@@ -1,3 +1,4 @@
use crate::ai::agent::RenderableAIError;
use crate::ai::blocklist::task_status_sync_model::classify_renderable_error;
use crate::server::server_api::ai::TaskStatusUpdate;
use galaxy_graphql::ai::{AgentTaskState, PlatformErrorCode};
@@ -66,7 +67,7 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
AgentDriverError::WarpDriveSyncFailed => (
AgentTaskState::Error,
TaskStatusUpdate::with_error_code(
"Warp Drive failed to sync. Please check your network connection and try again.",
"Galaxy Drive failed to sync. Please check your network connection and try again.",
PlatformErrorCode::InternalError,
),
),
@@ -76,7 +77,7 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
AgentTaskState::Error,
TaskStatusUpdate::with_error_code(
format!(
"Authentication required. Log in via '{bin} login', provide an API key via '--api-key', or set the WARP_API_KEY environment variable."
"Authentication required. Log in via '{bin} login', provide an API key via '--api-key', or set the GALAXY_API_KEY environment variable."
),
PlatformErrorCode::AuthenticationRequired,
),
@@ -95,7 +96,7 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
"MCP server {uuid} was not found. Verify the server exists in your Warp Drive and the UUID is correct."
"MCP server {uuid} was not found. Verify the server exists in your Galaxy Drive and the UUID is correct."
),
PlatformErrorCode::EnvironmentSetupFailed,
),
@@ -125,7 +126,7 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
"Agent profile \"{name}\" not found. Check the profile ID and ensure it exists in your team's Warp Drive."
"Agent profile \"{name}\" not found. Check the profile ID and ensure it exists in your team's Galaxy Drive."
),
PlatformErrorCode::ResourceNotFound,
),
@@ -134,7 +135,7 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
AgentTaskState::Failed,
TaskStatusUpdate::with_error_code(
format!(
"Saved prompt not found for ID {id}. Verify the prompt exists in your Warp Drive."
"Saved prompt not found for ID {id}. Verify the prompt exists in your Galaxy Drive."
),
PlatformErrorCode::ResourceNotFound,
),
@@ -302,6 +303,31 @@ pub fn classify_driver_error(error: &AgentDriverError) -> (AgentTaskState, TaskS
}
}
/// Returns true if an `AgentDriverError` represents a transient condition that
/// a subagent can recover from by retrying (e.g., network issues, rate limits,
/// server overload). Permanent errors (auth, config, cancelled) return false.
pub fn is_self_recoverable(error: &AgentDriverError) -> bool {
match error {
AgentDriverError::ConversationError { error: renderable } => {
matches!(
renderable,
RenderableAIError::ServerOverloaded
| RenderableAIError::Other {
will_attempt_resume: true,
..
}
)
}
AgentDriverError::WarpDriveSyncFailed
| AgentDriverError::TeamMetadataRefreshTimeout
| AgentDriverError::ShareSessionFailed {
error: ShareSessionError::Timeout,
..
} => true,
_ => false,
}
}
#[cfg(test)]
#[path = "error_classification_tests.rs"]
mod tests;
+5 -5
View File
@@ -194,7 +194,7 @@ impl EnvironmentCommandRunner {
ctx.spawn(initial_sync, move |_, result, ctx| {
if result.is_err() {
super::report_fatal_error(
anyhow::anyhow!("Timed out waiting for Warp Drive to sync"),
anyhow::anyhow!("Timed out waiting for Galaxy Drive to sync"),
ctx,
);
return;
@@ -265,7 +265,7 @@ impl EnvironmentCommandRunner {
ctx.spawn(initial_sync, move |_, result, ctx| {
if result.is_err() {
super::report_fatal_error(
anyhow::anyhow!("Timed out waiting for Warp Drive to sync"),
anyhow::anyhow!("Timed out waiting for Galaxy Drive to sync"),
ctx,
);
return;
@@ -474,7 +474,7 @@ impl EnvironmentCommandRunner {
ctx.spawn(initial_sync, move |_, result, ctx| {
if result.is_err() {
super::report_fatal_error(
anyhow::anyhow!("Timed out waiting for Warp Drive to sync"),
anyhow::anyhow!("Timed out waiting for Galaxy Drive to sync"),
ctx,
);
return;
@@ -856,7 +856,7 @@ impl EnvironmentCommandRunner {
ctx.spawn(initial_sync, move |_, result, ctx| {
if result.is_err() {
super::report_fatal_error(
anyhow::anyhow!("Timed out waiting for Warp Drive to sync"),
anyhow::anyhow!("Timed out waiting for Galaxy Drive to sync"),
ctx,
);
return;
@@ -1035,7 +1035,7 @@ impl EnvironmentCommandRunner {
ctx.spawn(initial_sync, move |_, result, ctx| {
if result.is_err() {
super::report_fatal_error(
anyhow::anyhow!("Timed out waiting for Warp Drive to sync"),
anyhow::anyhow!("Timed out waiting for Galaxy Drive to sync"),
ctx,
);
return;
+3 -3
View File
@@ -175,7 +175,7 @@ fn dispatch_command(
schedule::run(ctx, global_options, schedule_cmd)
}
CliCommand::Secret(secret_cmd) => {
if !FeatureFlag::WarpManagedSecrets.is_enabled() {
if !FeatureFlag::GalaxyManagedSecrets.is_enabled() {
return Err(anyhow::anyhow!("invalid value 'secret'"));
}
secret::run(ctx, global_options, secret_cmd)
@@ -547,7 +547,7 @@ impl AgentDriverRunner {
// Ensure we've synced team state before starting the driver.
Self::refresh_team_metadata(&foreground).await?;
// Wait for Warp Drive to sync before building the task config, since
// Wait for Galaxy Drive to sync before building the task config, since
// prompt resolution (SavedPrompt -> workflow lookup) and environment
// resolution (CloudAmbientAgentEnvironment lookup) depend on it.
if foreground
@@ -1328,7 +1328,7 @@ fn launch_command(
dispatched = true;
let auth_state = AuthStateProvider::handle(ctx).as_ref(ctx).get();
let message = if auth_state.is_api_key_authenticated() {
"Your API key is invalid. Please provide a valid key via '--api-key' or the WARP_API_KEY environment variable.".to_string()
"Your API key is invalid. Please provide a valid key via '--api-key' or the GALAXY_API_KEY environment variable.".to_string()
} else {
format!("Your credentials are invalid. Please log in again with `{cli_name} login`.")
};
+1 -1
View File
@@ -72,7 +72,7 @@ pub fn run(
global_options: GlobalOptions,
command: SecretCommand,
) -> Result<()> {
if !FeatureFlag::WarpManagedSecrets.is_enabled() {
if !FeatureFlag::GalaxyManagedSecrets.is_enabled() {
return Err(anyhow::anyhow!("This feature is not enabled"));
}
+4 -4
View File
@@ -118,7 +118,7 @@ static DEFAULT_TIPS: LazyLock<Vec<AgentTip>> = LazyLock::new(|| {
description: "Store reusable workflows, notebooks, and prompts in your".to_string(),
link: Some("https://docs.warp.dev/knowledge-and-collaboration/warp-drive".to_string()),
binding_name: None,
action: Some(WorkspaceAction::OpenWarpDrive),
action: Some(WorkspaceAction::OpenGalaxyDrive),
kind: AgentTipKind::WarpDrive,
},
AgentTip {
@@ -129,7 +129,7 @@ static DEFAULT_TIPS: LazyLock<Vec<AgentTip>> = LazyLock::new(|| {
kind: AgentTipKind::General,
},
AgentTip {
description: "`@` to add context from files, blocks, or Warp Drive objects to your prompt.".to_string(),
description: "`@` to add context from files, blocks, or Galaxy Drive objects to your prompt.".to_string(),
link: Some("https://docs.warp.dev/agent-platform/local-agents/agent-context/using-to-add-context".to_string()),
binding_name: None,
action: None,
@@ -297,7 +297,7 @@ static DEFAULT_TIPS: LazyLock<Vec<AgentTip>> = LazyLock::new(|| {
kind: AgentTipKind::General,
},
AgentTip {
description: "`/init` to generate a `WARP.md` file and define project rules for the agent.".to_string(),
description: "`/init` to generate a `GALAXY.md` file and define project rules for the agent.".to_string(),
link: Some("https://docs.warp.dev/agent-platform/capabilities/rules".to_string()),
binding_name: None,
action: None,
@@ -411,7 +411,7 @@ impl WorkspaceAction {
pub fn display_text(&self) -> Option<String> {
match self {
WorkspaceAction::OpenPalette { .. } => Some("Open palette".to_string()),
WorkspaceAction::OpenWarpDrive => Some("Warp Drive.".to_string()),
WorkspaceAction::OpenGalaxyDrive => Some("Galaxy Drive.".to_string()),
WorkspaceAction::ToggleRightPanel => Some("Show diff view".to_string()),
_ => None,
}
+3 -3
View File
@@ -610,7 +610,7 @@ impl AIDocumentView {
let appearance = Appearance::as_ref(app);
let ui_builder = appearance.ui_builder().clone();
let tooltip = ui_builder
.tool_tip("Save and auto-sync this plan to your Warp Drive".to_string())
.tool_tip("Save and auto-sync this plan to your Galaxy Drive".to_string())
.build()
.finish();
let sync_button_mouse_state = self.sync_button_mouse_state.clone();
@@ -663,7 +663,7 @@ impl AIDocumentView {
let color = theme.nonactive_ui_detail().into_solid();
let ui_builder = appearance.ui_builder().clone();
let tooltip_text =
"This plan is synced to your Warp Drive and will auto save any edits you make."
"This plan is synced to your Galaxy Drive and will auto save any edits you make."
.to_string();
let synced_status_mouse_state = self.synced_status_mouse_state.clone();
Container::new(
@@ -1233,7 +1233,7 @@ impl BackingView for AIDocumentView {
.into_item(),
);
menu_items.push(
MenuItemFields::new("Show in Warp Drive")
MenuItemFields::new("Show in Galaxy Drive")
.with_on_select_action(AIDocumentAction::ShowInWarpDrive)
.with_icon(Icon::WarpDrive)
.into_item(),
+2 -2
View File
@@ -4,7 +4,7 @@ use anyhow::anyhow;
use chrono::{DateTime, Utc};
use galaxy_cli::agent::Harness;
use galaxy_core::report_error;
use galaxy_core::ui::theme::WarpTheme;
use galaxy_core::ui::theme::GalaxyTheme;
use galaxyui::color::ColorU;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
@@ -406,7 +406,7 @@ impl AmbientAgentTaskState {
}
}
pub fn status_icon_and_color(&self, theme: &WarpTheme) -> (Icon, ColorU) {
pub fn status_icon_and_color(&self, theme: &GalaxyTheme) -> (Icon, ColorU) {
match self {
AmbientAgentTaskState::Queued
| AmbientAgentTaskState::Pending
+2
View File
@@ -140,6 +140,7 @@ impl BedrockClient {
user_query: Option<String>,
diagnostic_logger: Option<Arc<BedrockDiagnosticLogger>>,
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
is_summarization: bool,
) -> Result<ResponseStream, BedrockError> {
let effective_model_id = if cross_region_inference {
apply_cross_region_prefix(model_id, &self.region)
@@ -228,6 +229,7 @@ impl BedrockClient {
diagnostic_logger,
messages_sent,
effective_model_id,
is_summarization,
)))
}
+49 -7
View File
@@ -175,6 +175,20 @@ pub fn extract_new_input_messages(request: &api::Request) -> Vec<ConversationMes
),
});
}
api::request::input::Type::SummarizeConversation(summarize) => {
let prompt = if summarize.prompt.is_empty() {
"Please summarize this conversation so far, preserving key decisions, \
code changes, and important context. Be concise but retain all \
information needed to continue the work."
.to_string()
} else {
summarize.prompt.clone()
};
results.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(prompt),
});
}
_ => {}
}
@@ -378,6 +392,30 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
});
}
}
api::request::input::Type::SummarizeConversation(summarize) => {
let prompt = if summarize.prompt.is_empty() {
"Please summarize this conversation so far, preserving key decisions, \
code changes, and important context. Be concise but retain all \
information needed to continue the work."
.to_string()
} else {
summarize.prompt.clone()
};
results.push(api::Message {
id: uuid::Uuid::new_v4().to_string(),
task_id: task_id.clone(),
request_id: String::new(),
timestamp: None,
server_message_data: String::new(),
citations: vec![],
message: Some(api::message::Message::UserQuery(
api::message::UserQuery {
query: prompt,
..Default::default()
},
)),
});
}
_ => {}
}
@@ -1171,8 +1209,9 @@ pub fn extract_messages_from_request(request: &api::Request) -> Vec<Conversation
messages
}
#[cfg(test)]
fn convert_proto_message_for_test(msg: &api::Message) -> Option<ConversationMessage> {
/// Converts a proto `api::Message` into a `ConversationMessage` for the Bedrock message history.
/// Used to rebuild the message history from persisted task messages on session restore.
pub fn convert_proto_message(msg: &api::Message) -> Option<ConversationMessage> {
let message_content = msg.message.as_ref()?;
match message_content {
api::message::Message::UserQuery(query) => Some(ConversationMessage {
@@ -1184,7 +1223,7 @@ fn convert_proto_message_for_test(msg: &api::Message) -> Option<ConversationMess
content: MessageContent::Text(output.text.clone()),
}),
api::message::Message::ToolCall(tool_call) => {
let (name, input) = extract_tool_call_info_for_test(tool_call);
let (name, input) = extract_tool_call_info(tool_call);
Some(ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::ToolUse {
@@ -1214,11 +1253,8 @@ fn convert_proto_message_for_test(msg: &api::Message) -> Option<ConversationMess
}
}
#[cfg(test)]
#[allow(deprecated)]
fn extract_tool_call_info_for_test(
tool_call: &api::message::ToolCall,
) -> (String, serde_json::Value) {
fn extract_tool_call_info(tool_call: &api::message::ToolCall) -> (String, serde_json::Value) {
if let Some(tool) = &tool_call.tool {
match tool {
api::message::tool_call::Tool::RunShellCommand(cmd) => (
@@ -1254,6 +1290,12 @@ fn extract_tool_call_info_for_test(
}
}
#[cfg(test)]
fn convert_proto_message_for_test(msg: &api::Message) -> Option<ConversationMessage> {
convert_proto_message(msg)
}
#[cfg(test)]
#[path = "request_translator_tests.rs"]
mod tests;
+4 -1
View File
@@ -65,6 +65,7 @@ pub fn bedrock_stream_to_response_events(
diagnostic_logger: Option<Arc<BedrockDiagnosticLogger>>,
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
model_id: String,
is_summarization: bool,
) -> BoxStream<'static, Event> {
let request_id = Uuid::new_v4().to_string();
let conversation_id = Uuid::new_v4().to_string();
@@ -470,6 +471,7 @@ pub fn bedrock_stream_to_response_events(
cache_read_input_tokens,
cache_write_input_tokens,
&model_id,
is_summarization,
);
yield Ok(finished_event);
};
@@ -555,6 +557,7 @@ pub(super) fn build_stream_finished(
cache_read_input_tokens: i32,
cache_write_input_tokens: i32,
model_id: &str,
is_summarization: bool,
) -> ResponseEvent {
let total_tokens =
(input_tokens + output_tokens + cache_read_input_tokens + cache_write_input_tokens) as u32;
@@ -598,7 +601,7 @@ pub(super) fn build_stream_finished(
#[allow(deprecated)]
let conversation_usage_metadata = Some(stream_finished::ConversationUsageMetadata {
context_window_usage: context_usage,
summarized: false,
summarized: is_summarization,
credits_spent: 0.0,
token_usage: vec![],
tool_usage_metadata: None,
+2
View File
@@ -14,6 +14,7 @@ pub struct TranslatorRequest {
pub root_task_id: Option<String>,
pub bedrock_message_history: Vec<ConversationMessage>,
pub bedrock_messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
pub is_summarization: bool,
}
pub async fn execute(
@@ -100,6 +101,7 @@ pub async fn execute(
user_query_text,
diagnostic_logger,
params.bedrock_messages_sent.clone(),
params.is_summarization,
)
.await?;
@@ -6,7 +6,7 @@ use crate::ai::agent::ReadSkillResult;
use crate::ai::agent::{AIAgentAction, AIAgentActionId, AIAgentActionType};
use crate::ai::blocklist::action_model::AIConversationId;
use crate::ai::skills::SkillManager;
use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher;
use crate::galaxy_managed_paths_watcher::GalaxyManagedPathsWatcher;
use ai::skills::{parse_skill, SkillReference};
use galaxyui::App;
use repo_metadata::{
@@ -22,7 +22,7 @@ fn initialize_app(app: &mut App) {
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
app.add_singleton_model(GalaxyManagedPathsWatcher::new_for_testing);
app.add_singleton_model(SkillManager::new);
}
+1
View File
@@ -8,6 +8,7 @@ mod inline_agent_view_header;
// TODO: Move orchestration_conversation_links module import elsewhere.
pub(crate) mod orchestration_conversation_links;
pub mod shortcuts;
pub(crate) mod subagent_inline_panel;
mod zero_state_block;
pub use agent_input_footer::*;
@@ -0,0 +1,343 @@
//! Inline subagent panel rendered within the parent agent's chat flow.
//!
//! 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,
MainAxisAlignment, MainAxisSize, MouseStateHandle, ParentElement, Radius, Shrinkable, Text,
};
use galaxyui::{AppContext, SingletonEntity};
use pathfinder_color::ColorU;
use warp_multi_agent_api as api;
use crate::ai::agent::conversation::{AIConversationId, ConversationStatus};
use crate::ai::blocklist::inline_action::inline_action_header::{
ICON_MARGIN, INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
};
use crate::ai::blocklist::inline_action::inline_action_icons::icon_size;
use crate::ai::blocklist::BlocklistAIHistoryModel;
use crate::appearance::Appearance;
use crate::ui_components::blended_colors;
use crate::ui_components::icons::Icon;
const MINI_TRANSCRIPT_MAX_LINES: usize = 8;
const PANEL_MAX_HEIGHT: f32 = 200.;
const PANEL_CORNER_RADIUS: f32 = 8.;
/// State for a single subagent inline panel instance.
#[derive(Debug, Clone)]
pub struct SubagentPanelState {
pub conversation_id: AIConversationId,
pub is_expanded: bool,
pub header_mouse_state: MouseStateHandle,
pub expand_button_mouse_state: MouseStateHandle,
pub cancel_button_mouse_state: MouseStateHandle,
}
impl SubagentPanelState {
pub fn new(conversation_id: AIConversationId) -> Self {
Self {
conversation_id,
is_expanded: false,
header_mouse_state: MouseStateHandle::default(),
expand_button_mouse_state: MouseStateHandle::default(),
cancel_button_mouse_state: MouseStateHandle::default(),
}
}
}
/// Renders the inline subagent panel for a child conversation.
pub fn render_subagent_inline_panel(
state: &SubagentPanelState,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let history_model = BlocklistAIHistoryModel::as_ref(app);
let Some(conversation) = history_model.conversation(&state.conversation_id) else {
return Empty::new().finish();
};
let status = conversation.status().clone();
let agent_name = conversation
.agent_name()
.unwrap_or("Subagent")
.to_string();
let panel_bg = blended_colors::neutral_2(theme);
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,
));
// Body (mini-transcript) — only when expanded
if state.is_expanded {
let transcript_lines = collect_mini_transcript(&state.conversation_id, app);
if !transcript_lines.is_empty() {
column.add_child(render_mini_transcript(&transcript_lines, panel_bg, app));
}
}
// Footer — show summary when complete
if status.is_done() {
if let Some(summary) = get_completion_summary(&state.conversation_id, app) {
column.add_child(render_summary_footer(&summary, panel_bg, app));
}
}
Container::new(column.finish())
.with_background_color(panel_bg)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(PANEL_CORNER_RADIUS)))
.with_margin_top(4.)
.with_margin_bottom(4.)
.finish()
}
fn render_panel_header(
agent_name: &str,
status: &ConversationStatus,
state: &SubagentPanelState,
_background: ColorU,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let font_family = appearance.ui_font_family();
let font_size = appearance.monospace_font_size();
let surface = theme.surface_2();
let mut header_row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_main_axis_size(MainAxisSize::Max)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
// Left: status icon + agent name + status text
let mut left_side = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
let (icon, icon_color) = status.status_icon_and_color(theme);
let status_icon_element = ConstrainedBox::new(
galaxyui::elements::Icon::new(icon.into(), icon_color).finish(),
)
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
left_side.add_child(
Container::new(status_icon_element)
.with_margin_right(ICON_MARGIN)
.finish(),
);
let name_color: ColorU = theme.main_text_color(surface).into();
left_side.add_child(
Text::new_inline(agent_name.to_string(), font_family, font_size)
.with_color(name_color)
.finish(),
);
let status_text = match status {
ConversationStatus::InProgress => "Working...",
ConversationStatus::Success => "Complete",
ConversationStatus::Error => "Error",
ConversationStatus::Cancelled => "Cancelled",
ConversationStatus::Blocked { .. } => "Blocked",
};
let status_text_color = blended_colors::text_disabled(theme, surface);
left_side.add_child(
Container::new(
Text::new_inline(status_text.to_string(), font_family, font_size)
.with_color(status_text_color)
.finish(),
)
.with_margin_left(8.)
.finish(),
);
header_row.add_child(Shrinkable::new(1.0, left_side.finish()).finish());
// Right: collapse/expand chevron
let mut right_side = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
let chevron_icon = if state.is_expanded {
Icon::ChevronDown
} else {
Icon::ChevronRight
};
let chevron_color = blended_colors::text_disabled(theme, surface);
let chevron = ConstrainedBox::new(
galaxyui::elements::Icon::new(chevron_icon.into(), chevron_color).finish(),
)
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
right_side.add_child(
Container::new(chevron)
.with_margin_right(4.)
.finish(),
);
header_row.add_child(right_side.finish());
Container::new(header_row.finish())
.with_padding_left(INLINE_ACTION_HORIZONTAL_PADDING)
.with_padding_right(INLINE_ACTION_HORIZONTAL_PADDING)
.with_padding_top(INLINE_ACTION_HEADER_VERTICAL_PADDING)
.with_padding_bottom(INLINE_ACTION_HEADER_VERTICAL_PADDING)
.finish()
}
fn collect_mini_transcript(
conversation_id: &AIConversationId,
app: &AppContext,
) -> Vec<String> {
let history_model = BlocklistAIHistoryModel::as_ref(app);
let Some(conversation) = history_model.conversation(conversation_id) else {
return vec![];
};
let mut lines = Vec::new();
let messages = conversation.all_linearized_messages();
for msg in messages.iter().rev().take(MINI_TRANSCRIPT_MAX_LINES * 2) {
if let Some(text) = extract_message_text(msg) {
let truncated = if text.len() > 120 {
format!("{}...", &text[..117])
} else {
text
};
lines.push(truncated);
if lines.len() >= MINI_TRANSCRIPT_MAX_LINES {
break;
}
}
}
lines.reverse();
lines
}
fn extract_message_text(msg: &api::Message) -> Option<String> {
let message_content = msg.message.as_ref()?;
match message_content {
api::message::Message::AgentOutput(output) => {
if output.text.is_empty() {
None
} else {
Some(output.text.clone())
}
}
api::message::Message::UserQuery(query) => {
if query.query.is_empty() {
None
} else {
Some(query.query.clone())
}
}
_ => None,
}
}
fn render_mini_transcript(
lines: &[String],
background: ColorU,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let text_color = blended_colors::text_disabled(theme, background);
let font_family = appearance.ui_font_family();
let font_size = appearance.monospace_font_size() - 1.;
let mut column = Flex::column();
for line in lines {
let prefixed = format!("> {line}");
column.add_child(
Text::new_inline(prefixed, font_family, font_size)
.with_color(text_color)
.finish(),
);
}
ConstrainedBox::new(
Container::new(column.finish())
.with_padding_left(INLINE_ACTION_HORIZONTAL_PADDING)
.with_padding_right(INLINE_ACTION_HORIZONTAL_PADDING)
.with_padding_top(4.)
.with_padding_bottom(4.)
.finish(),
)
.with_max_height(PANEL_MAX_HEIGHT)
.finish()
}
fn get_completion_summary(
conversation_id: &AIConversationId,
app: &AppContext,
) -> Option<String> {
let history_model = BlocklistAIHistoryModel::as_ref(app);
let conversation = history_model.conversation(conversation_id)?;
if !conversation.status().is_done() {
return None;
}
let messages = conversation.all_linearized_messages();
for msg in messages.iter().rev() {
if let Some(text) = extract_message_text(msg) {
if !text.is_empty() {
let truncated = if text.len() > 300 {
format!("{}...", &text[..297])
} else {
text
};
return Some(truncated);
}
}
}
None
}
fn render_summary_footer(
summary: &str,
_background: ColorU,
app: &AppContext,
) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let surface = theme.surface_2();
let text_color: ColorU = theme.main_text_color(surface).into();
let label_color = blended_colors::text_disabled(theme, surface);
let font_family = appearance.ui_font_family();
let font_size = appearance.monospace_font_size();
let mut row = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Start);
row.add_child(
Text::new_inline("Summary: ".to_string(), font_family, font_size)
.with_color(label_color)
.finish(),
);
row.add_child(
Shrinkable::new(
1.0,
Text::new_inline(summary.to_string(), font_family, font_size)
.with_color(text_color)
.finish(),
)
.finish(),
);
Container::new(row.finish())
.with_padding_left(INLINE_ACTION_HORIZONTAL_PADDING)
.with_padding_right(INLINE_ACTION_HORIZONTAL_PADDING)
.with_padding_top(6.)
.with_padding_bottom(6.)
.finish()
}
+86
View File
@@ -90,6 +90,7 @@ use crate::ai::blocklist::inline_action::aws_bedrock_credentials_error::{
use crate::ai::blocklist::inline_action::search_codebase::{
SearchCodebaseView, SearchCodebaseViewEvent,
};
use crate::ai::blocklist::inline_action::summarization::SummarizationView;
use crate::ai::blocklist::inline_action::web_fetch::WebFetchView;
use crate::ai::blocklist::inline_action::web_search::WebSearchView;
use crate::ai::facts::{AIFact, AIMemory, CloudAIFactModel};
@@ -830,6 +831,9 @@ pub struct AIBlock {
/// Map from web fetch message IDs to their view handles.
web_fetch_views: HashMap<MessageId, ViewHandle<WebFetchView>>,
/// Map from summarization message IDs to their view handles.
summarization_views: HashMap<MessageId, ViewHandle<SummarizationView>>,
/// Map from todo list IDs to their states.
todo_list_states: HashMap<MessageId, TodoListElementState>,
@@ -1340,6 +1344,7 @@ impl AIBlock {
search_codebase_view: Default::default(),
web_search_views: Default::default(),
web_fetch_views: Default::default(),
summarization_views: Default::default(),
requested_commands_to_auto_collapse: Default::default(),
review_changes_button,
open_all_comments_button,
@@ -1801,6 +1806,9 @@ impl AIBlock {
self.handle_web_fetch_messages(&output.messages, ctx);
}
self.handle_summarization_messages(&output.messages, ctx);
self.maybe_create_summarization_view_from_input(ctx);
for action in output.actions() {
let new_action_ids: HashSet<AIAgentActionId> =
output.actions().map(|action| action.id.clone()).collect();
@@ -3489,6 +3497,84 @@ impl AIBlock {
}
}
fn handle_summarization_messages(
&mut self,
messages: &[AIAgentOutputMessage],
ctx: &mut ViewContext<Self>,
) {
use crate::ai::agent::SummarizationType;
for message in messages {
let AIAgentOutputMessageType::Summarization {
finished_duration,
summarization_type,
..
} = &message.message
else {
continue;
};
if !matches!(summarization_type, SummarizationType::ConversationSummary) {
continue;
}
if let Some(view) = self.summarization_views.get(&message.id) {
if finished_duration.is_some() {
view.update(ctx, |view, ctx| {
view.mark_finished();
ctx.notify();
});
}
} else {
let is_finished = finished_duration.is_some();
let view = ctx.add_view(|ctx| {
let mut v = SummarizationView::new(ctx);
if is_finished {
v.mark_finished();
}
v
});
self.summarization_views.insert(message.id.clone(), view);
ctx.notify();
}
}
}
/// Creates a SummarizationView when the exchange input is a SummarizeConversation.
/// This handles the Bedrock path where no Summarization output message is emitted.
fn maybe_create_summarization_view_from_input(&mut self, ctx: &mut ViewContext<Self>) {
let is_summarize_input = self
.model
.inputs_to_render(ctx)
.iter()
.any(|i| matches!(i, AIAgentInput::SummarizeConversation { .. }));
if !is_summarize_input {
return;
}
let key = MessageId::new("__summarization_inline_view__".to_string());
if self.summarization_views.contains_key(&key) {
// Already created — check if we should mark it finished
let is_complete = !self.model.status(ctx).is_streaming();
if is_complete {
if let Some(view) = self.summarization_views.get(&key) {
view.update(ctx, |view, ctx| {
if !view.is_finished {
view.mark_finished();
ctx.notify();
}
});
}
}
return;
}
let view = ctx.add_view(|ctx| SummarizationView::new(ctx));
self.summarization_views.insert(key, view);
ctx.notify();
}
/// Note this is called when the search codebase tool call definition finishes streaming, not when the search actually completes.
fn handle_search_codebase_complete(
&mut self,
+3 -2
View File
@@ -86,7 +86,7 @@ use galaxy_core::ui::color::contrast::{
foreground_color_with_minimum_contrast, MinimumAllowedContrast,
};
use galaxy_core::ui::color::Rgb;
use galaxy_core::ui::theme::{Fill, WarpTheme};
use galaxy_core::ui::theme::{Fill, GalaxyTheme};
use galaxyui::elements::{Highlight, HighlightedRange, Text};
use galaxyui::fonts::Properties;
use galaxyui::platform::Cursor;
@@ -433,7 +433,7 @@ pub(crate) fn add_highlights_to_rich_text(
find_context: Option<FindContext<'_>>,
location_index: usize,
line_count: usize,
theme: &WarpTheme,
theme: &GalaxyTheme,
is_selecting: bool,
is_action: bool,
app: &AppContext,
@@ -1080,6 +1080,7 @@ impl View for AIBlock {
search_codebase_view: &self.search_codebase_view,
web_search_views: &self.web_search_views,
web_fetch_views: &self.web_fetch_views,
summarization_views: &self.summarization_views,
review_changes_button: &self.review_changes_button,
open_all_comments_button: &self.open_all_comments_button,
dismiss_suggestion_button: &self.dismiss_suggestion_button,
+45 -21
View File
@@ -88,6 +88,7 @@ use crate::{
},
requested_command::RequestedCommand,
search_codebase::SearchCodebaseView,
summarization::SummarizationView,
suggested_unit_tests::SuggestedUnitTestsView,
web_fetch::WebFetchView,
web_search::WebSearchView,
@@ -177,6 +178,7 @@ pub(crate) struct Props<'a> {
pub(super) search_codebase_view: &'a HashMap<AIAgentActionId, ViewHandle<SearchCodebaseView>>,
pub(super) web_search_views: &'a HashMap<MessageId, ViewHandle<WebSearchView>>,
pub(super) web_fetch_views: &'a HashMap<MessageId, ViewHandle<WebFetchView>>,
pub(super) summarization_views: &'a HashMap<MessageId, ViewHandle<SummarizationView>>,
pub(super) review_changes_button: &'a ViewHandle<ActionButton>,
pub(super) open_all_comments_button: &'a ViewHandle<ActionButton>,
pub(super) dismiss_suggestion_button: &'a ViewHandle<ActionButton>,
@@ -210,6 +212,23 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
let conversation_status = props.model.conversation(app).map(|c| c.status());
let is_conversation_in_progress = conversation_status.is_some_and(|s| s.is_in_progress());
// If this is a summarization request, render the inline SummarizationView at the top
// regardless of output status. This handles the Bedrock path where no Summarization
// output message type is emitted.
let is_summarize_input = props
.model
.inputs_to_render(app)
.iter()
.any(|i| matches!(i, AIAgentInput::SummarizeConversation { .. }));
if is_summarize_input {
let key = crate::ai::agent::MessageId::new(
"__summarization_inline_view__".to_string(),
);
if let Some(summarization_view) = props.summarization_views.get(&key) {
output_items.add_child(ChildView::new(summarization_view).finish());
}
}
let status = props.model.status(app);
match status {
// Ignore errors if the response is not yet complete-- it could be a deserialization
@@ -807,21 +826,28 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
} if matches!(
summarization_type,
SummarizationType::ConversationSummary
) && !are_all_text_sections_empty(&text.sections) =>
) =>
{
let header_text = "Conversation summarized".to_string();
if let Some(element) = render_collapsible_block(
output_message,
header_text,
&text.sections,
finished_duration.is_some(),
props,
&mut has_rendered_first_text_section,
&mut text_section_index,
&mut code_section_index,
app,
) {
output_items.add_child(element);
if let Some(summarization_view) =
props.summarization_views.get(&output_message.id)
{
output_items
.add_child(ChildView::new(summarization_view).finish());
} else if !are_all_text_sections_empty(&text.sections) {
let header_text = "Conversation summarized".to_string();
if let Some(element) = render_collapsible_block(
output_message,
header_text,
&text.sections,
finished_duration.is_some(),
props,
&mut has_rendered_first_text_section,
&mut text_section_index,
&mut code_section_index,
app,
) {
output_items.add_child(element);
}
}
}
AIAgentOutputMessageType::WebSearch(web_search_status) => {
@@ -3210,19 +3236,18 @@ fn render_usage_button(props: Props, app: &AppContext) -> Box<dyn Element> {
};
let context_usage = conversation.context_window_usage();
let total_input = conversation.total_input_tokens();
let current_context = conversation.current_context_tokens();
let cache_read = conversation.total_cache_read_tokens();
let cache_write = conversation.total_cache_write_tokens();
let cache_miss = conversation.cache_miss_tokens();
let cost_cents = conversation.total_cost_cents();
let max_context: u32 = if context_usage > 0.0 {
(total_input as f32 / context_usage).round() as u32
(current_context as f32 / context_usage).round() as u32
} else {
200_000
};
let context_pct = context_usage * 100.0;
let cache_total = cache_read + cache_write + cache_miss;
let cache_total = cache_read + cache_write;
let cache_hit_pct = if cache_total > 0 {
(cache_read as f64 / cache_total as f64) * 100.0
} else {
@@ -3230,14 +3255,13 @@ fn render_usage_button(props: Props, app: &AppContext) -> Box<dyn Element> {
};
let usage_text = format!(
"Context: {:.1}% ({} / {}) | Cache: {:.1}% (R: {}, W: {}, M: {}) | Cost: ${:.2}",
"Context: {:.1}% ({} / {}) | Cache: {:.1}% (R: {}, W: {}) | Cost: ${:.2}",
context_pct,
format_token_count(total_input),
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(cache_miss),
cost_cents / 100.0,
);
+1 -1
View File
@@ -254,7 +254,7 @@ fn render_linked_code_block_internal(
let open_button = render_button(
appearance,
Icon::LinkExternal,
"Open in Warp",
"Open in Galaxy",
mouse_handles.open_button,
code_clone.clone(),
on_open,
+126 -5
View File
@@ -1995,6 +1995,9 @@ impl BlocklistAIController {
request_params.parent_agent_id = parent_agent_id;
request_params.agent_name = agent_name;
request_params.bedrock_message_history = bedrock_history;
request_params.is_summarization = request_input
.all_inputs()
.any(|input| matches!(input, AIAgentInput::SummarizeConversation { .. }));
let server_conversation_token_for_identifiers =
conversation_data.server_conversation_token.clone();
@@ -2020,9 +2023,13 @@ impl BlocklistAIController {
let input_contains_user_query = request_input
.all_inputs()
.any(|input| input.is_user_query());
let input_is_summarization = request_input
.all_inputs()
.any(|input| matches!(input, AIAgentInput::SummarizeConversation { .. }));
ctx.subscribe_to_model(&response_stream, move |me, event, ctx| {
me.handle_response_stream_event(
input_contains_user_query,
input_is_summarization,
event,
&response_stream_clone,
ctx,
@@ -2212,6 +2219,7 @@ impl BlocklistAIController {
fn handle_response_stream_event(
&mut self,
did_input_contain_user_query: bool,
is_summarization_request: bool,
event: &ResponseStreamEvent,
response_stream: &ModelHandle<ResponseStream>,
ctx: &mut ModelContext<Self>,
@@ -2319,11 +2327,88 @@ impl BlocklistAIController {
let history_model = BlocklistAIHistoryModel::handle(ctx);
history_model.update(ctx, |history_model, _| {
if let Some(conversation) = history_model.conversation_mut(&conversation_id) {
*conversation.bedrock_message_history_mut() = new_history;
log::info!(
"[bedrock] Updated conversation bedrock history: {} messages",
conversation.bedrock_message_history().len()
);
// If this was a summarization request, compact the
// history to just the summary instead of keeping
// the full message list. This is what actually
// frees up context window space.
let is_summarization = is_summarization_request;
if is_summarization {
// Extract the assistant's summary from the last
// message in the history (the response).
let summary_text = new_history
.iter()
.rev()
.find_map(|msg| {
use crate::ai::bedrock::convert::{
MessageContent, MessageRole,
};
if msg.role == MessageRole::Assistant {
if let MessageContent::Text(text) =
&msg.content
{
Some(text.clone())
} else {
None
}
} else {
None
}
});
if let Some(summary) = summary_text {
use crate::ai::bedrock::convert::{
ConversationMessage, MessageContent,
MessageRole,
};
let assistant_reply = "Understood. I have the context from our previous conversation. How can I help you next?";
let user_msg = format!(
"Here is a summary of our conversation so far:\n\n{summary}"
);
let compacted = vec![
ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(user_msg.clone()),
},
ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::Text(
assistant_reply.to_string()
),
},
];
log::info!(
"[bedrock] Compacted conversation history from {} messages to {} (summary)",
new_history.len(),
compacted.len()
);
*conversation.bedrock_message_history_mut() =
compacted;
// Estimate new context size from the compacted content.
// ~4 chars per token is a reasonable approximation.
let estimated_tokens = ((user_msg.len() + assistant_reply.len()) / 4) as u32;
let max_context = crate::ai::bedrock::response_translator::context_window_for_model("claude-opus-4-6-20250514[1m]");
let new_usage = estimated_tokens as f32 / max_context as f32;
conversation.set_context_window_usage(new_usage);
conversation.set_current_context_tokens(estimated_tokens);
log::info!(
"[bedrock] Post-compact context estimate: ~{} tokens ({:.1}% of context window)",
estimated_tokens,
new_usage * 100.0
);
} else {
*conversation.bedrock_message_history_mut() =
new_history;
}
} else {
*conversation.bedrock_message_history_mut() =
new_history;
log::info!(
"[bedrock] Updated conversation bedrock history: {} messages",
conversation.bedrock_message_history().len()
);
}
}
});
}
@@ -2800,6 +2885,42 @@ impl BlocklistAIController {
});
ctx.emit(BlocklistAIControllerEvent::FreeTierLimitCheckTriggered);
}
// Auto-compact: trigger summarization when context window usage >= 85%.
let should_auto_compact = {
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
history_model
.conversation(&conversation_id)
.is_some_and(|conversation| {
let is_summarization_request = conversation
.latest_exchange()
.is_some_and(|exchange| {
exchange
.input
.iter()
.any(|i| matches!(i, AIAgentInput::SummarizeConversation { .. }))
});
conversation.context_window_usage() >= 0.85
&& !conversation.has_pending_auto_compact()
&& !is_summarization_request
})
};
if should_auto_compact {
log::info!(
"[auto-compact] Context window usage >= 85% for conversation {:?}, triggering summarization",
conversation_id
);
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
if let Some(conversation) = history_model.conversation_mut(&conversation_id) {
conversation.set_has_pending_auto_compact(true);
}
});
self.send_slash_command_request(
SlashCommandRequest::Summarize { prompt: None },
ctx,
);
}
}
}
@@ -4,7 +4,7 @@ use ai::agent::{
action::{AskUserQuestionItem, AskUserQuestionOption, AskUserQuestionType},
action_result::{AskUserQuestionAnswerItem, AskUserQuestionResult},
};
use galaxy_core::ui::theme::{color::internal_colors, WarpTheme};
use galaxy_core::ui::theme::{color::internal_colors, GalaxyTheme};
use galaxyui::{
elements::{
new_scrollable::SingleAxisConfig, Border, ChildView, Clipped, ClippedScrollStateHandle,
@@ -1333,7 +1333,7 @@ impl AskUserQuestionView {
fn render_question_text(
question_text: &str,
appearance: &Appearance,
theme: &WarpTheme,
theme: &GalaxyTheme,
) -> Box<dyn Element> {
let text_color = theme.foreground().into();
Container::new(render_text_with_markdown_support(
@@ -1357,7 +1357,7 @@ impl AskUserQuestionView {
&self,
question_text: &str,
appearance: &Appearance,
theme: &WarpTheme,
theme: &GalaxyTheme,
) -> Box<dyn Element> {
let body = Flex::column()
.with_cross_axis_alignment(CrossAxisAlignment::Stretch)
@@ -1385,7 +1385,7 @@ impl AskUserQuestionView {
fn render_nav_footer(
&self,
appearance: &Appearance,
theme: &WarpTheme,
theme: &GalaxyTheme,
app: &AppContext,
) -> Box<dyn Element> {
let counter = format!(
@@ -11,6 +11,7 @@ pub(crate) mod requested_command_attribution;
pub(crate) mod requested_script;
pub(super) mod search_codebase;
pub(crate) mod search_results_common;
pub(super) mod summarization;
pub(crate) mod suggested_unit_tests;
pub(super) mod web_fetch;
pub(super) mod web_search;
@@ -0,0 +1,197 @@
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::shimmering_text::{ShimmerConfig, ShimmeringTextElement, ShimmeringTextStateHandle};
use galaxyui::elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Flex,
MainAxisAlignment, ParentElement, Radius, Shrinkable, Text,
};
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
use galaxyui::{AppContext, Entity, SingletonEntity, View, ViewContext};
use instant::Instant;
use std::time::Duration;
use super::inline_action_header::{
INLINE_ACTION_HEADER_VERTICAL_PADDING, INLINE_ACTION_HORIZONTAL_PADDING,
};
use super::inline_action_icons::icon_size;
use crate::ai::blocklist::block::view_impl::WithContentItemSpacing;
use crate::ui_components::icons::Icon;
pub enum SummarizationViewEvent {}
pub struct SummarizationView {
pub is_finished: bool,
shimmering_text_handle: ShimmeringTextStateHandle,
start_time: Instant,
timer_handle: Option<SpawnedFutureHandle>,
}
impl SummarizationView {
pub fn new(ctx: &mut ViewContext<Self>) -> Self {
let mut view = Self {
is_finished: false,
shimmering_text_handle: ShimmeringTextStateHandle::default(),
start_time: Instant::now(),
timer_handle: None,
};
view.start_timer(ctx);
view
}
pub fn mark_finished(&mut self) {
self.is_finished = true;
if let Some(handle) = self.timer_handle.take() {
handle.abort();
}
}
fn start_timer(&mut self, ctx: &mut ViewContext<Self>) {
if self.timer_handle.is_some() {
return;
}
let handle = ctx.spawn(
async move {
Timer::after(Duration::from_secs(1)).await;
},
|me, _unit, ctx| {
me.timer_handle = None;
if !me.is_finished {
ctx.notify();
me.start_timer(ctx);
}
},
);
self.timer_handle = Some(handle);
}
fn render_in_progress(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let header_background = theme.surface_2();
let mut header_row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::Start)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
// Clock loader icon (magenta, matches InProgress convention)
let icon_element = galaxyui::elements::Icon::new(
Icon::ClockLoader.into(),
theme.ansi_fg_magenta(),
)
.finish();
let icon_box = ConstrainedBox::new(icon_element)
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
header_row.add_child(
Container::new(icon_box)
.with_margin_right(8.)
.finish(),
);
// Shimmering "Summarizing conversation..." text
let base_color = theme.disabled_text_color(header_background).into_solid();
let shimmer_color = theme.main_text_color(header_background).into_solid();
let shimmer_element = ShimmeringTextElement::new(
"Summarizing conversation...".to_string(),
appearance.ui_font_family(),
appearance.monospace_font_size(),
base_color,
shimmer_color,
ShimmerConfig::default(),
self.shimmering_text_handle.clone(),
)
.finish();
header_row.add_child(Shrinkable::new(1.0, shimmer_element).finish());
// Elapsed time suffix
let elapsed = self.start_time.elapsed();
let elapsed_text = format_elapsed(elapsed);
let suffix = Text::new_inline(
format!(" \u{2022} {elapsed_text}"),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(theme.disabled_text_color(header_background).into())
.finish();
header_row.add_child(suffix);
Container::new(header_row.finish())
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
.with_background(header_background)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish()
}
fn render_finished(&self, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let header_background = theme.surface_2();
let mut header_row = Flex::row()
.with_main_axis_alignment(MainAxisAlignment::Start)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
// Checkmark-style icon for completed
let icon_element = galaxyui::elements::Icon::new(
Icon::Check.into(),
theme.ansi_fg_green(),
)
.finish();
let icon_box = ConstrainedBox::new(icon_element)
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
header_row.add_child(
Container::new(icon_box)
.with_margin_right(8.)
.finish(),
);
let elapsed = self.start_time.elapsed();
let elapsed_text = format_elapsed(elapsed);
let title = Text::new_inline(
format!("Conversation summarized \u{2022} {elapsed_text}"),
appearance.ui_font_family(),
appearance.monospace_font_size(),
)
.with_color(theme.main_text_color(header_background).into())
.finish();
header_row.add_child(Shrinkable::new(1.0, title).finish());
Container::new(header_row.finish())
.with_horizontal_padding(INLINE_ACTION_HORIZONTAL_PADDING)
.with_vertical_padding(INLINE_ACTION_HEADER_VERTICAL_PADDING)
.with_background(header_background)
.with_corner_radius(CornerRadius::with_all(Radius::Pixels(8.)))
.finish()
}
}
impl Entity for SummarizationView {
type Event = SummarizationViewEvent;
}
impl View for SummarizationView {
fn ui_name() -> &'static str {
"SummarizationView"
}
fn render(&self, app: &AppContext) -> Box<dyn Element> {
let element = if self.is_finished {
self.render_finished(app)
} else {
self.render_in_progress(app)
};
element.with_agent_output_item_spacing(app).finish()
}
}
fn format_elapsed(duration: Duration) -> String {
let secs = duration.as_secs();
if secs < 60 {
format!("{secs}s")
} else {
format!("{}m {}s", secs / 60, secs % 60)
}
}
+180 -14
View File
@@ -19,6 +19,8 @@ use warp_multi_agent_api as api;
const MAX_RETRY_ATTEMPTS: i32 = 3;
const MAX_PENDING_LIFECYCLE_EVENTS_PER_TARGET: usize = 200;
pub const MAX_SUBAGENT_RETRIES: u8 = 3;
const MAX_SUBAGENT_QUESTION_DEPTH: u8 = 3;
/// Stage associated with a lifecycle error detail.
/// This keeps persisted/runtime metadata consistent across API payloads and DB rows.
@@ -64,6 +66,23 @@ pub enum PendingEventDetail {
Lifecycle {
event: api::AgentEvent,
},
/// A subagent is asking its parent a question (routed from AskUserQuestion).
SubagentQuestion {
source_conversation_id: AIConversationId,
question_text: String,
options: Vec<String>,
depth: u8,
},
/// The parent's answer to a subagent's question.
SubagentAnswer {
target_conversation_id: AIConversationId,
answer_text: String,
},
/// A subagent reporting its completion summary to the parent.
SubagentCompletionSummary {
source_conversation_id: AIConversationId,
summary_text: String,
},
}
/// A queued event consumed by the controller.
@@ -930,29 +949,49 @@ impl OrchestrationEventService {
let mut messages = Vec::new();
let mut lifecycle_events = Vec::new();
for event in &deliverable {
let mut server_bound_events = Vec::new();
for event in deliverable {
match &event.detail {
PendingEventDetail::Message {
message_id,
addresses,
subject,
message_body,
} => messages.push(ReceivedMessageInput {
message_id: message_id.clone(),
sender_agent_id: event.source_agent_id.clone(),
addresses: addresses.clone(),
subject: subject.clone(),
message_body: message_body.clone(),
}),
PendingEventDetail::Lifecycle { event } => lifecycle_events.push(event.clone()),
} => {
messages.push(ReceivedMessageInput {
message_id: message_id.clone(),
sender_agent_id: event.source_agent_id.clone(),
addresses: addresses.clone(),
subject: subject.clone(),
message_body: message_body.clone(),
});
server_bound_events.push(event);
}
PendingEventDetail::Lifecycle { event: _ } => {
lifecycle_events.push(
if let PendingEventDetail::Lifecycle { event: e } = &event.detail {
e.clone()
} else {
unreachable!()
},
);
server_bound_events.push(event);
}
// Local-only subagent events are consumed directly by the controller,
// not converted to AIAgentInput or awaited for server echo.
PendingEventDetail::SubagentQuestion { .. }
| PendingEventDetail::SubagentAnswer { .. }
| PendingEventDetail::SubagentCompletionSummary { .. } => {}
}
}
// Move to awaiting echo for delivery confirmation.
self.awaiting_server_echo_events
.entry(conversation_id)
.or_default()
.extend(deliverable);
// Only server-bound events need echo confirmation.
if !server_bound_events.is_empty() {
self.awaiting_server_echo_events
.entry(conversation_id)
.or_default()
.extend(server_bound_events);
}
let mut inputs = Vec::new();
if !messages.is_empty() {
@@ -966,6 +1005,34 @@ impl OrchestrationEventService {
inputs
}
/// Drain only the local subagent events (Question/Answer/Summary) for a conversation.
/// These are not sent to the server and are consumed directly by the controller.
pub fn drain_subagent_events(
&mut self,
conversation_id: &AIConversationId,
) -> Vec<PendingEvent> {
let Some(pending) = self.pending_events.get_mut(conversation_id) else {
return vec![];
};
let mut subagent_events = Vec::new();
pending.retain(|event| match &event.detail {
PendingEventDetail::SubagentQuestion { .. }
| PendingEventDetail::SubagentAnswer { .. }
| PendingEventDetail::SubagentCompletionSummary { .. } => {
subagent_events.push(event.clone());
false
}
_ => true,
});
if pending.is_empty() {
self.pending_events.remove(conversation_id);
}
subagent_events
}
/// Moves all awaiting events back to pending for retry after a failed
/// send attempt. Increments attempt counts and drops events that have
/// exhausted their retry limit.
@@ -1109,6 +1176,101 @@ impl OrchestrationEventService {
self.awaiting_server_echo_events.remove(&conversation_id);
}
}
/// Route a subagent's AskUserQuestion to the parent conversation for silent auto-answer.
pub fn route_subagent_question_to_parent(
&mut self,
child_conversation_id: AIConversationId,
parent_conversation_id: AIConversationId,
question_text: String,
options: Vec<String>,
depth: u8,
ctx: &mut ModelContext<Self>,
) {
if depth >= MAX_SUBAGENT_QUESTION_DEPTH {
log::warn!(
"Subagent question depth limit reached for conversation {:?}",
child_conversation_id
);
return;
}
let event = PendingEvent {
event_id: Uuid::new_v4().to_string(),
source_agent_id: child_conversation_id.to_string(),
attempt_count: 0,
detail: PendingEventDetail::SubagentQuestion {
source_conversation_id: child_conversation_id,
question_text,
options,
depth,
},
};
self.pending_events
.entry(parent_conversation_id)
.or_default()
.push(event);
ctx.emit(OrchestrationEventServiceEvent::EventsReady {
conversation_id: parent_conversation_id,
});
}
/// Route the parent's answer back to the child subagent.
pub fn route_answer_to_subagent(
&mut self,
child_conversation_id: AIConversationId,
answer_text: String,
ctx: &mut ModelContext<Self>,
) {
let event = PendingEvent {
event_id: Uuid::new_v4().to_string(),
source_agent_id: "parent".to_string(),
attempt_count: 0,
detail: PendingEventDetail::SubagentAnswer {
target_conversation_id: child_conversation_id,
answer_text,
},
};
self.pending_events
.entry(child_conversation_id)
.or_default()
.push(event);
ctx.emit(OrchestrationEventServiceEvent::EventsReady {
conversation_id: child_conversation_id,
});
}
/// Route a subagent's completion summary to the parent conversation.
pub fn route_subagent_completion_summary(
&mut self,
child_conversation_id: AIConversationId,
parent_conversation_id: AIConversationId,
summary_text: String,
ctx: &mut ModelContext<Self>,
) {
let event = PendingEvent {
event_id: Uuid::new_v4().to_string(),
source_agent_id: child_conversation_id.to_string(),
attempt_count: 0,
detail: PendingEventDetail::SubagentCompletionSummary {
source_conversation_id: child_conversation_id,
summary_text,
},
};
self.pending_events
.entry(parent_conversation_id)
.or_default()
.push(event);
ctx.emit(OrchestrationEventServiceEvent::EventsReady {
conversation_id: parent_conversation_id,
});
}
}
/// `None` means \"subscribe to all lifecycle types\" (input omitted).
@@ -1135,6 +1297,10 @@ fn did_event_round_trip_through_server(
PendingEventDetail::Lifecycle { event } => {
echoed_lifecycle_event_ids.contains(event.event_id.as_str())
}
// Local-only events never round-trip through the server.
PendingEventDetail::SubagentQuestion { .. }
| PendingEventDetail::SubagentAnswer { .. }
| PendingEventDetail::SubagentCompletionSummary { .. } => false,
}
}
@@ -33,11 +33,14 @@ pub struct ConversationUsageInfo {
pub lines_added: i32,
pub lines_removed: i32,
pub commands_executed: i32,
pub total_input_tokens: u32,
pub total_output_tokens: u32,
pub total_cache_read_tokens: u32,
pub total_cache_write_tokens: u32,
/// Live context window token count (from most recent Bedrock response).
pub current_context_tokens: u32,
/// Cumulative cost across all requests.
pub estimated_cost_cents: f32,
/// Cumulative cache read tokens (session total).
pub total_cache_read_tokens: u32,
/// Cumulative cache write tokens (session total).
pub total_cache_write_tokens: u32,
}
/// Timing information for the last set of agent responses
@@ -246,32 +249,21 @@ impl ConversationUsageView {
);
}
// Token usage section
let total_tokens = self.usage_info.total_input_tokens
+ self.usage_info.total_output_tokens
+ self.usage_info.total_cache_read_tokens
// Context tokens (live state — current context window size)
if self.usage_info.current_context_tokens > 0 {
labels.push(render_label_text("Context tokens", appearance));
values.push(render_value_text(
format_token_count(self.usage_info.current_context_tokens),
appearance,
));
}
// Cache usage (cumulative session totals)
let total_cache = self.usage_info.total_cache_read_tokens
+ self.usage_info.total_cache_write_tokens;
if total_tokens > 0 {
labels.push(render_label_text("Total tokens", appearance));
values.push(render_value_text(
format_token_count(total_tokens),
appearance,
));
labels.push(render_label_text(" Input", appearance));
values.push(render_value_text(
format_token_count(self.usage_info.total_input_tokens),
appearance,
));
labels.push(render_label_text(" Output", appearance));
values.push(render_value_text(
format_token_count(self.usage_info.total_output_tokens),
appearance,
));
if total_cache > 0 {
if self.usage_info.total_cache_read_tokens > 0 {
labels.push(render_label_text(" Cache read", appearance));
labels.push(render_label_text("Cache read", appearance));
values.push(render_value_text(
format_token_count(self.usage_info.total_cache_read_tokens),
appearance,
@@ -279,12 +271,25 @@ impl ConversationUsageView {
}
if self.usage_info.total_cache_write_tokens > 0 {
labels.push(render_label_text(" Cache write", appearance));
labels.push(render_label_text("Cache write", appearance));
values.push(render_value_text(
format_token_count(self.usage_info.total_cache_write_tokens),
appearance,
));
}
// 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;
labels.push(render_label_text("Cache hit rate", appearance));
values.push(render_value_text(
format!("{:.0}%", hit_rate),
appearance,
));
}
}
labels.push(render_label_text("Context window used", appearance));
+2 -2
View File
@@ -1,4 +1,4 @@
use galaxy_core::ui::theme::{Fill, WarpTheme};
use galaxy_core::ui::theme::{Fill, GalaxyTheme};
use galaxy_core::ui::Icon;
use galaxyui::Element;
@@ -33,7 +33,7 @@ pub fn icon_for_context_window_usage(context_window_usage: f32) -> Icon {
pub fn render_context_window_usage_icon(
context_window_usage: f32,
theme: &WarpTheme,
theme: &GalaxyTheme,
color_override: Option<Fill>,
) -> Box<dyn Element> {
let icon = icon_for_context_window_usage(context_window_usage);
+3 -3
View File
@@ -19,7 +19,7 @@ use pathfinder_color::ColorU;
use pathfinder_geometry::vector::vec2f;
use crate::{
themes::theme::{AnsiColorIdentifier, Fill, WarpTheme},
themes::theme::{AnsiColorIdentifier, Fill, GalaxyTheme},
ui_components::icons::Icon,
};
@@ -52,7 +52,7 @@ pub const CLAUDE_ORANGE: ColorU = ColorU {
/// Returns the color to be used for various AI signifiers
/// input with AI mode).
pub fn ai_brand_color(theme: &WarpTheme) -> ColorU {
pub fn ai_brand_color(theme: &GalaxyTheme) -> ColorU {
AnsiColorIdentifier::Magenta
.to_ansi_color(&theme.terminal_colors().normal)
.into()
@@ -60,7 +60,7 @@ pub fn ai_brand_color(theme: &WarpTheme) -> ColorU {
/// Returns the color to be used for error UI throughout Agent Mode (like the "request limit
/// exceeded" chip).
pub fn error_color(theme: &WarpTheme) -> ColorU {
pub fn error_color(theme: &GalaxyTheme) -> ColorU {
AnsiColorIdentifier::Red
.to_ansi_color(&theme.terminal_colors().normal)
.into()
+4 -4
View File
@@ -1,6 +1,6 @@
use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::color::coloru_with_opacity;
use galaxy_core::ui::theme::{Fill, WarpTheme};
use galaxy_core::ui::theme::{Fill, GalaxyTheme};
use galaxyui::color::ColorU;
use galaxyui::elements::{ConstrainedBox, Container, CornerRadius, Radius};
use galaxyui::Element;
@@ -13,17 +13,17 @@ use crate::ui_components::icons::Icon;
pub const STATUS_ELEMENT_PADDING: f32 = 2.;
pub trait StatusElementStyle {
fn status_icon_and_color(&self, theme: &WarpTheme) -> (Icon, ColorU);
fn status_icon_and_color(&self, theme: &GalaxyTheme) -> (Icon, ColorU);
}
impl StatusElementStyle for ConversationStatus {
fn status_icon_and_color(&self, theme: &WarpTheme) -> (Icon, ColorU) {
fn status_icon_and_color(&self, theme: &GalaxyTheme) -> (Icon, ColorU) {
ConversationStatus::status_icon_and_color(self, theme)
}
}
impl StatusElementStyle for AgentRunDisplayStatus {
fn status_icon_and_color(&self, theme: &WarpTheme) -> (Icon, ColorU) {
fn status_icon_and_color(&self, theme: &GalaxyTheme) -> (Icon, ColorU) {
AgentRunDisplayStatus::status_icon_and_color(self, theme)
}
}
@@ -664,7 +664,7 @@ pub fn render_plan_auto_sync_toggle(
.finish();
let desc_elem = Text::new(
"The plans this agent creates will be automatically added and synced to Warp Drive."
"The plans this agent creates will be automatically added and synced to Galaxy Drive."
.to_string(),
appearance.ui_font_family(),
11.,
+20 -11
View File
@@ -5,49 +5,58 @@ pub struct PredefinedRule {
pub const SYSTEM_DEFINED_RULE_PREFIX: &str = "System Defined Rule";
pub fn is_predefined_rule(name: &str) -> bool {
PREDEFINED_RULES.iter().any(|r| r.name == name)
|| name.starts_with(SYSTEM_DEFINED_RULE_PREFIX)
}
pub fn predefined_rule_index(name: &str) -> Option<usize> {
PREDEFINED_RULES.iter().position(|r| r.name == name)
}
pub const PREDEFINED_RULES: &[PredefinedRule] = &[
PredefinedRule {
name: "System Defined Rule #1",
name: "Correctness Over Speed",
content: "Prioritize correctness, completeness, and reliability over speed.",
},
PredefinedRule {
name: "System Defined Rule #2",
name: "Never Guess",
content: "Never guess. If uncertain, explicitly say so and verify before finalizing.",
},
PredefinedRule {
name: "System Defined Rule #3",
name: "Evidence-Based Claims",
content: "Ground non-trivial claims in evidence (repo files, command output, tests, official documentation).",
},
PredefinedRule {
name: "System Defined Rule #4",
name: "Verify When Uncertain",
content: "If confidence is not high, or if a claim depends on external/current behavior, perform web verification before answering; prioritize official docs and cross-check with at least one additional reliable source.",
},
PredefinedRule {
name: "System Defined Rule #5",
name: "Separate Facts From Assumptions",
content: "Clearly separate facts, assumptions, and hypotheses.",
},
PredefinedRule {
name: "System Defined Rule #6",
name: "Ask When Ambiguous",
content: "Ask clarifying questions when ambiguity could change the solution or implementation.",
},
PredefinedRule {
name: "System Defined Rule #7",
name: "Validate Code Changes",
content: "For code changes, run relevant validations when available (tests, lint, typecheck, build) and report what was run, what passed/failed, and what was not run.",
},
PredefinedRule {
name: "System Defined Rule #8",
name: "Disclose Unvalidated Risks",
content: "If validation cannot be run, state that explicitly and describe residual risk and recommended manual checks.",
},
PredefinedRule {
name: "System Defined Rule #9",
name: "Admit Unknowns",
content: "Prefer \"I don't know yet\" over plausible speculation.",
},
PredefinedRule {
name: "System Defined Rule #10",
name: "Challenge Bad Ideas",
content: "If the user's idea is wrong, incomplete, risky, or non-optimal, say so directly and respectfully; explain why it may fail and provide a better alternative that still achieves the user's goal.",
},
PredefinedRule {
name: "System Defined Rule #11",
name: "Surface Disagreement",
content: "Do not hide uncertainty, and do not avoid technical disagreement when correctness is at stake.",
},
];
+5 -18
View File
@@ -1,7 +1,4 @@
use crate::cloud_object::{
CloudObject, CloudObjectSyncStatus, GenericStringObjectFormat, JsonObjectType,
};
use crate::drive::CloudObjectTypeAndId;
use crate::cloud_object::{CloudObject, CloudObjectSyncStatus};
use crate::network::NetworkStatus;
use crate::pane_group::focus_state::PaneFocusHandle;
use crate::pane_group::{pane::view, BackingView, PaneConfiguration, PaneEvent};
@@ -337,22 +334,12 @@ pub fn is_online(app: &AppContext) -> bool {
NetworkStatus::as_ref(app).is_online()
}
pub fn is_delete_allowed(ai_fact: CloudAIFact, app: &AppContext) -> bool {
let cloud_object_type_and_id = CloudObjectTypeAndId::GenericStringObject {
object_type: GenericStringObjectFormat::Json(JsonObjectType::AIFact),
id: ai_fact.sync_id(),
};
is_online(app)
&& cloud_object_type_and_id.has_server_id()
&& !ai_fact.metadata().has_pending_online_only_change()
pub fn is_delete_allowed(_ai_fact: CloudAIFact, _app: &AppContext) -> bool {
true
}
pub fn is_edit_allowed(ai_fact: CloudAIFact, app: &AppContext) -> bool {
let cloud_object_type_and_id = CloudObjectTypeAndId::GenericStringObject {
object_type: GenericStringObjectFormat::Json(JsonObjectType::AIFact),
id: ai_fact.sync_id(),
};
is_online(app) || !cloud_object_type_and_id.has_server_id()
pub fn is_edit_allowed(_ai_fact: CloudAIFact, _app: &AppContext) -> bool {
true
}
pub fn is_syncing(ai_fact: CloudAIFact, app: &AppContext) -> bool {
+80 -6
View File
@@ -46,8 +46,12 @@ use markdown_parser::{
use std::fmt::Debug;
use std::path::PathBuf;
use super::{is_edit_allowed, is_syncing, style, AIFact, CloudAIFact, CloudAIFactModel};
use crate::ai::facts::predefined_rules::{PREDEFINED_RULES, SYSTEM_DEFINED_RULE_PREFIX};
use super::{
is_delete_allowed, is_edit_allowed, is_syncing, style, AIFact, CloudAIFact, CloudAIFactModel,
};
use crate::ai::facts::predefined_rules::{
is_predefined_rule, predefined_rule_index, PREDEFINED_RULES,
};
use crate::ai::facts::AIMemory;
pub const HEADER_TEXT: &str = "Rules";
@@ -56,7 +60,7 @@ const DESCRIPTION_TEXT: &str = "Rules enhance the agent by providing structured
const SEARCH_PLACEHOLDER_TEXT: &str = "Search rules";
const ZERO_STATE_TEXT: &str = "Once you add a rule, it will be shown here.";
const ZERO_STATE_TEXT_PROJECT: &str =
"Once you generate a WARP.md rules file for a project, it will appear here.";
"Once you generate a GALAXY.md rules file for a project, it will appear here.";
const DISABLED_BANNER_TEXT: &str =
"Your rules are disabled and won't be used as context in sessions. You can ";
@@ -84,6 +88,7 @@ pub enum RuleViewAction {
AddPredefinedRules,
InitializeProject,
Edit(SyncId),
Delete(SyncId),
OpenSettings,
SelectScope(RuleScope),
OpenFile(PathBuf),
@@ -94,6 +99,7 @@ pub struct MouseStateHandles {
pub hover: MouseStateHandle,
pub sync_status_hover: MouseStateHandle,
pub sync_status_icon: MouseStateHandle,
pub delete_hover: MouseStateHandle,
}
#[derive(Debug, Clone)]
@@ -358,13 +364,37 @@ impl RuleView {
.cloned()
.collect()
};
self.global_rules = ai_rules
let mut rows: Vec<CloudRuleRow> = ai_rules
.into_iter()
.map(|ai_fact| CloudRuleRow {
fact: ai_fact,
mouse_states: Default::default(),
})
.collect();
rows.sort_by(|a, b| {
let name_a = match &a.fact.model().string_model {
AIFact::Memory(AIMemory { name, .. }) => name.clone().unwrap_or_default(),
};
let name_b = match &b.fact.model().string_model {
AIFact::Memory(AIMemory { name, .. }) => name.clone().unwrap_or_default(),
};
let is_predefined_a = is_predefined_rule(&name_a);
let is_predefined_b = is_predefined_rule(&name_b);
match (is_predefined_a, is_predefined_b) {
(true, true) => {
let idx_a = predefined_rule_index(&name_a).unwrap_or(usize::MAX);
let idx_b = predefined_rule_index(&name_b).unwrap_or(usize::MAX);
idx_a.cmp(&idx_b)
}
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
(false, false) => std::cmp::Ordering::Equal,
}
});
self.global_rules = rows;
ctx.notify();
}
@@ -466,7 +496,7 @@ impl RuleView {
.filter_map(|row| {
let AIFact::Memory(AIMemory { ref name, .. }) = row.fact.model().string_model;
let name = name.as_deref().unwrap_or_default();
if name.starts_with(SYSTEM_DEFINED_RULE_PREFIX) {
if is_predefined_rule(name) {
Some((
name.to_string(),
(row.fact.sync_id(), row.fact.metadata().revision.clone()),
@@ -870,7 +900,8 @@ impl RuleView {
let mut row = Flex::row()
.with_main_axis_size(MainAxisSize::Max)
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween);
.with_main_axis_alignment(MainAxisAlignment::SpaceBetween)
.with_cross_axis_alignment(CrossAxisAlignment::Center);
if let Some(sync_status_icon) =
self.render_sync_status_icon(ai_row.clone(), appearance, app)
@@ -880,6 +911,45 @@ impl RuleView {
row.add_child(Expanded::new(1., fact_text).finish());
if is_delete_allowed(ai_row.fact.clone(), app) {
let delete_sync_id = ai_row.fact.sync_id();
let delete_button = Hoverable::new(
ai_row.mouse_states.delete_hover.clone(),
|state| {
let mut container = Container::new(
ConstrainedBox::new(
Icon::Trash
.to_galaxyui_icon(
appearance
.theme()
.sub_text_color(appearance.theme().background()),
)
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish(),
)
.with_uniform_padding(4.)
.with_corner_radius(CornerRadius::with_all(
galaxyui::elements::Radius::Pixels(4.),
));
if state.is_hovered() {
container =
container.with_background(appearance.theme().surface_2());
}
container.finish()
},
)
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(RuleViewAction::Delete(delete_sync_id));
})
.finish();
row.add_child(delete_button);
}
let mut hoverable = Hoverable::new(ai_row.mouse_states.hover.clone(), |state| {
let mut bg_color = internal_colors::neutral_1(appearance.theme());
if state.is_hovered() {
@@ -904,6 +974,7 @@ impl RuleView {
if is_edit_allowed(ai_row.fact.clone(), app) {
hoverable = hoverable
.with_cursor(Cursor::PointingHand)
.with_defer_events_to_children()
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(RuleViewAction::Edit(ai_row.fact.sync_id()));
});
@@ -1050,6 +1121,9 @@ impl TypedActionView for RuleView {
RuleViewAction::Edit(sync_id) => {
ctx.emit(RuleViewEvent::Edit(*sync_id));
}
RuleViewAction::Delete(sync_id) => {
self.delete_ai_rule(*sync_id, ctx);
}
RuleViewAction::OpenSettings => {
ctx.emit(RuleViewEvent::OpenSettings);
}
+5 -5
View File
@@ -14,7 +14,7 @@ use crate::{
ParsedTemplatableMCPServerResult,
},
settings::{ai::AISettings, AISettingsChangedEvent},
warp_managed_paths_watcher::warp_data_dir,
galaxy_managed_paths_watcher::galaxy_data_dir,
};
/// Singleton model to manage file-based MCP servers.
@@ -235,7 +235,7 @@ impl FileBasedMCPManager {
/// config location.
///
/// "Global" means the installation was detected outside of a user repository:
/// - For `MCPProvider::Warp`: `warp_data_dir()` (i.e. `~/.warp-core/.mcp.json`).
/// - For `MCPProvider::Warp`: `galaxy_data_dir()` (i.e. `~/.warp-core/.mcp.json`).
/// - For any other provider: the user's home directory (e.g. `~/.claude.json`).
///
/// Project-scoped installations (those detected inside a repo) are not considered
@@ -243,7 +243,7 @@ impl FileBasedMCPManager {
/// case this returns `true` due to the global reference).
fn is_global_server(&self, hash: u64) -> bool {
let home_dir = dirs::home_dir();
let warp_root = warp_data_dir();
let warp_root = galaxy_data_dir();
self.file_based_servers_by_root
.iter()
.any(|(root_path, provider_map)| {
@@ -264,7 +264,7 @@ impl FileBasedMCPManager {
/// Returns `true` if the server identified by `hash` is referenced from the global
/// Warp config (`~/.warp/.mcp.json`). Global Warp servers always auto-spawn.
fn is_global_warp_server(&self, hash: u64) -> bool {
let warp_root = warp_data_dir();
let warp_root = galaxy_data_dir();
self.file_based_servers_by_root
.get(&warp_root)
.and_then(|provider_map| provider_map.get(&MCPProvider::Warp))
@@ -432,7 +432,7 @@ impl FileBasedMCPManager {
// Global Warp installs live under `~/.warp-core/`, which is internal Warp state
// rather than a meaningful working directory. Map them to the home dir so
// all global installs (Warp and third-party) share a consistent cwd.
if discovery_root == warp_data_dir() {
if discovery_root == galaxy_data_dir() {
return dirs::home_dir().or(Some(discovery_root));
}
Some(discovery_root)
+3 -3
View File
@@ -3,7 +3,7 @@ use crate::ai::mcp::FileMCPWatcher;
use crate::ai::mcp::ParsedTemplatableMCPServerResult;
use crate::auth::AuthStateProvider;
use crate::settings::{AISettings, FocusedTerminalInfo};
use crate::warp_managed_paths_watcher::{warp_data_dir, WarpManagedPathsWatcher};
use crate::galaxy_managed_paths_watcher::{galaxy_data_dir, GalaxyManagedPathsWatcher};
use crate::workspaces::user_workspaces::UserWorkspaces;
use galaxy_core::features::FeatureFlag;
use galaxyui::{App, Entity, ModelHandle, SingletonEntity as _};
@@ -22,7 +22,7 @@ fn setup_app(app: &mut App) -> galaxyui::ModelHandle<FileBasedMCPManager> {
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
app.add_singleton_model(GalaxyManagedPathsWatcher::new_for_testing);
app.add_singleton_model(FileMCPWatcher::new);
app.add_singleton_model(AISettings::new_with_defaults);
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
@@ -260,7 +260,7 @@ fn test_update_file_based_servers_removes_unreferenced_servers() {
#[test]
fn test_global_warp_server_always_spawns() {
let _flag_guard = FeatureFlag::FileBasedMcp.override_enabled(true);
let warp_root = warp_data_dir();
let warp_root = galaxy_data_dir();
let parsed = parse_mcp_json(r#"{"global-warp": {"command": "npx", "args": ["warp"]}}"#);
App::test((), |mut app| async move {
+7 -7
View File
@@ -19,8 +19,8 @@ use crate::ai::mcp::{
home_config_file_path, parsing::normalize_codex_toml_to_json, MCPProvider,
ParsedTemplatableMCPServerResult,
};
use crate::warp_managed_paths_watcher::{
warp_managed_mcp_config_path, WarpManagedPathsWatcher, WarpManagedPathsWatcherEvent,
use crate::galaxy_managed_paths_watcher::{
galaxy_managed_mcp_config_path, GalaxyManagedPathsWatcher, GalaxyManagedPathsWatcherEvent,
};
use crate::HomeDirectoryWatcher;
use strum::IntoEnumIterator;
@@ -173,12 +173,12 @@ impl FileMCPWatcher {
ctx.subscribe_to_model(&HomeDirectoryWatcher::handle(ctx), |me, event, ctx| {
me.handle_home_directory_watcher_event(event, ctx);
});
ctx.subscribe_to_model(&WarpManagedPathsWatcher::handle(ctx), |me, event, ctx| {
ctx.subscribe_to_model(&GalaxyManagedPathsWatcher::handle(ctx), |me, event, ctx| {
me.handle_warp_managed_paths_event(event, ctx);
});
let mut home_provider_watchers = HashMap::new();
if let Some(mcp_config_path) = warp_managed_mcp_config_path() {
if let Some(mcp_config_path) = galaxy_managed_mcp_config_path() {
Self::spawn_config_parse(
mcp_config_path.config_path,
mcp_config_path.root_path,
@@ -418,11 +418,11 @@ impl FileMCPWatcher {
fn handle_warp_managed_paths_event(
&mut self,
event: &WarpManagedPathsWatcherEvent,
event: &GalaxyManagedPathsWatcherEvent,
ctx: &mut ModelContext<Self>,
) {
let WarpManagedPathsWatcherEvent::FilesChanged(update) = event;
let Some(mcp_config_path) = warp_managed_mcp_config_path() else {
let GalaxyManagedPathsWatcherEvent::FilesChanged(update) = event;
let Some(mcp_config_path) = galaxy_managed_mcp_config_path() else {
return;
};
let config_path = mcp_config_path.config_path;
+4 -4
View File
@@ -48,7 +48,7 @@ cfg_if::cfg_if! {
pub(crate) fn home_config_file_path(provider: MCPProvider) -> Option<PathBuf> {
match provider {
MCPProvider::Warp => galaxy_core::paths::warp_home_mcp_config_file_path(),
MCPProvider::Warp => galaxy_core::paths::galaxy_home_mcp_config_file_path(),
_ => dirs::home_dir().map(|home_dir| home_dir.join(provider.home_config_path())),
}
}
@@ -146,11 +146,11 @@ mod tests {
#[test]
fn mcp_provider_from_file_path_recognizes_warp_home_path() {
if let Some(warp_home_mcp_config_file_path) =
galaxy_core::paths::warp_home_mcp_config_file_path()
if let Some(galaxy_home_mcp_config_file_path) =
galaxy_core::paths::galaxy_home_mcp_config_file_path()
{
assert_eq!(
mcp_provider_from_file_path(&warp_home_mcp_config_file_path),
mcp_provider_from_file_path(&galaxy_home_mcp_config_file_path),
Some(MCPProvider::Warp)
);
}
@@ -15,9 +15,9 @@ use super::{
use watcher::{BulkFilesystemWatcherEvent, HomeDirectoryWatcher, HomeDirectoryWatcherEvent};
use crate::server::datetime_ext::DateTimeExt;
use crate::warp_managed_paths_watcher::{
filter_repository_update_by_prefix, warp_managed_skill_dirs, WarpManagedPathsWatcher,
WarpManagedPathsWatcherEvent,
use crate::galaxy_managed_paths_watcher::{
filter_repository_update_by_prefix, galaxy_managed_skill_dirs, GalaxyManagedPathsWatcher,
GalaxyManagedPathsWatcherEvent,
};
use ai::skills::{
home_skills_path, parse_skill, ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS,
@@ -124,7 +124,7 @@ impl SkillWatcher {
}
},
);
ctx.subscribe_to_model(&WarpManagedPathsWatcher::handle(ctx), |me, event, ctx| {
ctx.subscribe_to_model(&GalaxyManagedPathsWatcher::handle(ctx), |me, event, ctx| {
me.handle_warp_managed_paths_event(event, ctx);
});
}
@@ -138,7 +138,7 @@ impl SkillWatcher {
// We use a separate HomeDirectoryWatcher to detect when those are created and start watching them after they are created.
let mut home_provider_watchers = HashMap::new();
if let Some(home_path) = home_dir {
Self::spawn_read_skills_from_directories(warp_managed_skill_dirs(), ctx);
Self::spawn_read_skills_from_directories(galaxy_managed_skill_dirs(), ctx);
let skills_parent_paths: HashSet<PathBuf> = SKILL_PROVIDER_DEFINITIONS
.iter()
.filter(|provider| provider.provider != SkillProvider::Warp)
@@ -806,11 +806,11 @@ impl SkillWatcher {
fn handle_warp_managed_paths_event(
&mut self,
event: &WarpManagedPathsWatcherEvent,
event: &GalaxyManagedPathsWatcherEvent,
ctx: &mut ModelContext<Self>,
) {
let WarpManagedPathsWatcherEvent::FilesChanged(update) = event;
for skill_dir in warp_managed_skill_dirs() {
let GalaxyManagedPathsWatcherEvent::FilesChanged(update) = event;
for skill_dir in galaxy_managed_skill_dirs() {
if let Some(filtered_update) = filter_repository_update_by_prefix(update, &skill_dir) {
self.handle_repository_update(&filtered_update, ctx);
}
+3 -3
View File
@@ -12,7 +12,7 @@ use galaxyui::AppContext;
use regex::Regex;
use repo_metadata::{local_model::GetContentsArgs, RepoContent, RepoMetadataModel};
use crate::warp_managed_paths_watcher::warp_managed_skill_dirs;
use crate::galaxy_managed_paths_watcher::galaxy_managed_skill_dirs;
/// Finds all skill directories in a repository by querying the RepoMetadataModel tree.
///
@@ -100,7 +100,7 @@ pub fn extract_skill_parent_directory(path: &Path) -> Result<PathBuf, Error> {
&& path
.parent()
.and_then(Path::parent)
.is_some_and(|parent| warp_managed_skill_dirs().iter().any(|dir| parent == dir));
.is_some_and(|parent| galaxy_managed_skill_dirs().iter().any(|dir| parent == dir));
if is_warp_home_skill {
return dirs::home_dir()
.ok_or_else(|| anyhow::anyhow!("Home directory not available for {}", path.display()));
@@ -136,7 +136,7 @@ pub fn is_home_skill_directory(path: &Path) -> bool {
pub fn is_home_provider_path(path: &Path) -> bool {
SKILL_PROVIDER_DEFINITIONS.iter().any(|provider| {
if provider.provider == SkillProvider::Warp {
return warp_managed_skill_dirs().iter().any(|dir| path == dir);
return galaxy_managed_skill_dirs().iter().any(|dir| path == dir);
}
home_skills_path(provider.provider)
.as_ref()
@@ -327,7 +327,7 @@ fn is_home_provider_path_true_for_known_providers() {
let path = home_dir.join(".agents").join("skills");
assert!(is_home_provider_path(&path));
if let Some(path) = galaxy_core::paths::warp_home_skills_dir() {
if let Some(path) = galaxy_core::paths::galaxy_home_skills_dir() {
assert!(is_home_provider_path(&path));
}
@@ -350,12 +350,12 @@ fn extract_skill_parent_directory_returns_home_dir_for_warp_home_skill() {
eprintln!("Skipping test: home directory not available");
return;
};
let Some(warp_home_skills_dir) = galaxy_core::paths::warp_home_skills_dir() else {
let Some(galaxy_home_skills_dir) = galaxy_core::paths::galaxy_home_skills_dir() else {
eprintln!("Skipping test: Warp home skills directory not available");
return;
};
let skill_path = warp_home_skills_dir.join("test-skill").join("SKILL.md");
let skill_path = galaxy_home_skills_dir.join("test-skill").join("SKILL.md");
let result = extract_skill_parent_directory(&skill_path);
assert_eq!(result.ok(), Some(home_dir));
}
+2 -2
View File
@@ -23,7 +23,7 @@ use galaxyui::AppContext;
use galaxyui::SingletonEntity as _;
use super::SkillManager;
use crate::warp_managed_paths_watcher::warp_managed_skill_dirs;
use crate::galaxy_managed_paths_watcher::galaxy_managed_skill_dirs;
const SKILL_FILE_NAME: &str = "SKILL.md";
@@ -64,7 +64,7 @@ fn home_skill_dirs_for_resolution() -> Vec<PathBuf> {
let mut skill_dirs = Vec::new();
for provider in SKILL_PROVIDER_DEFINITIONS.iter() {
if provider.provider == SkillProvider::Warp {
for dir in warp_managed_skill_dirs() {
for dir in galaxy_managed_skill_dirs() {
push_unique_path(&mut skill_dirs, dir);
}
} else if let Some(dir) = home_skills_path(provider.provider) {
+7 -7
View File
@@ -1,5 +1,5 @@
use super::*;
use crate::warp_managed_paths_watcher::WarpManagedPathsWatcher;
use crate::galaxy_managed_paths_watcher::GalaxyManagedPathsWatcher;
use ai::skills::{ParsedSkill, SkillProvider, SkillScope};
use galaxy_core::channel::ChannelState;
use galaxyui::App;
@@ -74,7 +74,7 @@ fn get_skills_for_working_directory_scopes_subdirectory_skills() {
let repo_handle = app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
app.add_singleton_model(GalaxyManagedPathsWatcher::new_for_testing);
let skill_manager_handle = app.add_singleton_model(SkillManager::new);
// Register the repo root so get_root_for_path returns Some.
@@ -196,7 +196,7 @@ fn get_skills_for_working_directory_name_collision_returns_both() {
let repo_handle = app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
app.add_singleton_model(GalaxyManagedPathsWatcher::new_for_testing);
let skill_manager_handle = app.add_singleton_model(SkillManager::new);
// Register the repo root so get_root_for_path returns Some.
@@ -292,7 +292,7 @@ fn cloud_environment_skills_always_included() {
let repo_handle = app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
app.add_singleton_model(GalaxyManagedPathsWatcher::new_for_testing);
let skill_manager_handle = app.add_singleton_model(SkillManager::new);
let canonical_repo_a =
@@ -493,7 +493,7 @@ fn best_supported_provider_fast_path_returns_deduped_provider() {
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
app.add_singleton_model(GalaxyManagedPathsWatcher::new_for_testing);
let handle = app.add_singleton_model(SkillManager::new);
let claude_skill = make_skill("deploy", ".claude");
@@ -518,7 +518,7 @@ fn best_supported_provider_remaps_to_supported_provider() {
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
app.add_singleton_model(GalaxyManagedPathsWatcher::new_for_testing);
let handle = app.add_singleton_model(SkillManager::new);
let agents_skill = make_skill("deploy", ".agents");
@@ -548,7 +548,7 @@ fn best_supported_provider_falls_back_when_no_match() {
app.add_singleton_model(|_| DetectedRepositories::default());
app.add_singleton_model(RepoMetadataModel::new);
app.add_singleton_model(HomeDirectoryWatcher::new_for_test);
app.add_singleton_model(WarpManagedPathsWatcher::new_for_testing);
app.add_singleton_model(GalaxyManagedPathsWatcher::new_for_testing);
let handle = app.add_singleton_model(SkillManager::new);
let agents_skill = make_skill("deploy", ".agents");
+2 -2
View File
@@ -21,7 +21,7 @@ use std::hash::{Hash, Hasher};
use std::path::Path;
use std::path::PathBuf;
use crate::warp_managed_paths_watcher::warp_managed_skill_dirs;
use crate::galaxy_managed_paths_watcher::galaxy_managed_skill_dirs;
lazy_static! {
static ref CONTENT_HASHER: SipHasher = SipHasher::new_with_keys(0, 0);
@@ -167,7 +167,7 @@ pub fn icon_override_for_skill_name(name: &str) -> Option<Icon> {
pub fn skill_path_from_file_path(file_path: &Path) -> Option<PathBuf> {
for definition in SKILL_PROVIDER_DEFINITIONS.iter() {
let home_skill_dirs = if definition.provider == SkillProvider::Warp {
warp_managed_skill_dirs()
galaxy_managed_skill_dirs()
} else {
home_skills_path(definition.provider).into_iter().collect()
};
+3 -3
View File
@@ -14,18 +14,18 @@ fn test_skill_path_from_file_path_skill_md() {
#[test]
fn test_skill_path_from_file_path_warp_home_skill() {
let Some(warp_home_skills_dir) = galaxy_core::paths::warp_home_skills_dir() else {
let Some(galaxy_home_skills_dir) = galaxy_core::paths::galaxy_home_skills_dir() else {
eprintln!("Skipping test: Warp home skills directory not available");
return;
};
let warp_home_skill = warp_home_skills_dir
let warp_home_skill = galaxy_home_skills_dir
.join("my-skill")
.join("assets")
.join("image.png");
let result = skill_path_from_file_path(&warp_home_skill);
assert_eq!(
result,
Some(warp_home_skills_dir.join("my-skill").join("SKILL.md"))
Some(galaxy_home_skills_dir.join("my-skill").join("SKILL.md"))
);
}