use crate::{
agent::{
effective_internal_agent_reasoning, internal_agent_accepts_claude_runtime,
internal_agent_requires_model, AgentCatalog, AgentCatalogEntry, AgentOrigin,
AgentRuntimeSpec, ModelPolicy, ModelSelection, PromptPolicy,
},
config::{InternalAgentModelConfig, InternalAgentTarget},
};
use super::{
model_picker::{ClaudeCodeRows, ConversationModelRow, InternalAgentSelection},
picker::{DetailBlock, DetailField, DetailSheet, DetailTone, ExcerptAnchor, OverlayChrome},
ComposerMode, PickerBadge, PickerBadgeTone, PickerItem, PickerLayout, RuntimeModelView,
UiPicker,
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum InternalAgentModelPickerOrigin {
AgentsPicker,
AdvisorCommand,
AdvisorConfigRow,
AdvisorModelConfigRow,
PermissionModeConfigRow,
PermissionModeCommand,
PermissionClassifierModelConfigRow,
PermissionModeStartup,
}
impl InternalAgentModelPickerOrigin {
pub(super) fn opens_standalone(self) -> bool {
matches!(
self,
Self::AdvisorCommand | Self::PermissionModeStartup | Self::PermissionModeCommand
)
}
}
#[derive(Clone, Debug)]
pub(super) struct InternalAgentModelTarget {
pub(super) id: String,
pub(super) origin: InternalAgentModelPickerOrigin,
}
struct InternalAgentPickerModel {
current: InternalAgentSelection,
conversation_model: ConversationModelRow,
}
pub(super) struct AgentModelView<'a> {
provider: &'a str,
model: &'a str,
internal_agents: &'a std::collections::BTreeMap<String, InternalAgentModelConfig>,
}
impl<'a> From<&'a RuntimeModelView> for AgentModelView<'a> {
fn from(runtime: &'a RuntimeModelView) -> Self {
Self {
provider: &runtime.provider,
model: &runtime.model,
internal_agents: &runtime.internal_agents,
}
}
}
#[cfg(test)]
impl<'a> From<&'a crate::config::Config> for AgentModelView<'a> {
fn from(config: &'a crate::config::Config) -> Self {
Self {
provider: &config.provider,
model: &config.model,
internal_agents: &config.internal_agents,
}
}
}
const PROMPT_EXCERPT_ROWS: usize = 3;
pub(super) fn agent_picker(catalog: AgentCatalog, models: AgentModelView<'_>) -> UiPicker {
let mut entries = catalog.iter_with_internal().collect::<Vec<_>>();
entries.sort_by_key(|entry| agent_section(entry.metadata.origin).0);
let items = entries
.into_iter()
.map(|entry| agent_item(entry, &models))
.collect();
UiPicker::view_agent("Loaded agents", items)
.with_layout(PickerLayout::Overlay)
.with_overlay_chrome(OverlayChrome {
nav_label: " AGENTS".into(),
detail_label: Some(" DETAILS".into()),
nav_keys_hint: "↑↓ agents".into(),
})
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum AgentAccess {
Editable,
Internal,
ReadOnly,
}
impl AgentAccess {
fn of(origin: AgentOrigin) -> Self {
match origin {
AgentOrigin::RhoHome | AgentOrigin::Project => Self::Editable,
AgentOrigin::Internal => Self::Internal,
AgentOrigin::BuiltIn | AgentOrigin::AgentsHome | AgentOrigin::Workflow => {
Self::ReadOnly
}
}
}
fn selection_verb(self) -> &'static str {
match self {
Self::Editable => "edit",
Self::Internal => "configure",
Self::ReadOnly => "view prompt",
}
}
fn tone(self) -> PickerBadgeTone {
match self {
Self::Editable => PickerBadgeTone::Editable,
Self::Internal => PickerBadgeTone::Warning,
Self::ReadOnly => PickerBadgeTone::Muted,
}
}
fn marker(self) -> PickerBadge {
PickerBadge {
text: "●".into(),
tone: self.tone(),
}
}
fn tag(self) -> PickerBadge {
let text = match self {
Self::Editable => "● editable",
Self::Internal => "● internal",
Self::ReadOnly => "● read-only",
};
PickerBadge {
text: text.into(),
tone: self.tone(),
}
}
}
fn agent_section(origin: AgentOrigin) -> (u8, &'static str) {
match origin {
AgentOrigin::RhoHome | AgentOrigin::Project => (0, "YOURS"),
AgentOrigin::Workflow => (1, "WORKFLOW"),
AgentOrigin::AgentsHome => (2, "SHARED"),
AgentOrigin::BuiltIn => (3, "BUILT IN"),
AgentOrigin::Internal => (4, "INTERNAL"),
}
}
fn agent_item(entry: &AgentCatalogEntry, models: &AgentModelView<'_>) -> PickerItem {
let definition = &entry.definition;
let access = AgentAccess::of(entry.metadata.origin);
PickerItem {
section: Some(agent_section(entry.metadata.origin).1.into()),
label: definition.id.to_string(),
detail: Some(agent_detail(entry, access, models).into()),
preview: None,
badge: Some(access.marker()),
value: definition.id.to_string(),
selection_verb: Some(access.selection_verb()),
allow_filter_completion: true,
}
}
fn agent_detail(
entry: &AgentCatalogEntry,
access: AgentAccess,
models: &AgentModelView<'_>,
) -> DetailSheet {
let definition = &entry.definition;
let mut fields = vec![
DetailField::new(
"Runtime",
definition.runtime.runtime().to_string(),
DetailTone::Normal,
),
agent_model_field(entry, models),
agent_reasoning_field(entry, models),
agent_tools_field(definition),
];
if let AgentRuntimeSpec::ClaudeCli(config) = &definition.runtime {
fields.push(if config.inherit_claude_config {
DetailField::new("Claude config", "inherit", DetailTone::Normal)
} else {
DetailField::new("Claude config", "closed", DetailTone::Muted)
});
}
fields.push(agent_source_field(entry));
let mut blocks = vec![DetailBlock::Title {
text: definition.id.to_string(),
tag: Some(access.tag()),
}];
if !definition.description.is_empty() {
blocks.push(DetailBlock::Paragraph(definition.description.to_string()));
}
blocks.push(DetailBlock::Rule);
blocks.push(DetailBlock::Fields(fields));
blocks.push(DetailBlock::Rule);
blocks.extend(agent_prompt_blocks(definition, access));
DetailSheet { blocks }
}
fn agent_model_field(entry: &AgentCatalogEntry, models: &AgentModelView<'_>) -> DetailField {
let definition = &entry.definition;
if entry.metadata.origin == AgentOrigin::Internal {
return match models.internal_agents.get(definition.id.as_str()) {
Some(selection) => {
DetailField::new("Model", selection.display_reference(), DetailTone::Normal)
.with_note("override")
}
None if internal_agent_requires_model(definition.id.as_str()) => {
DetailField::new("Model", "not selected", DetailTone::Warning)
.with_note("no conversation fallback")
}
None => DetailField::new(
"Model",
rho_providers::provider::model_reference(models.provider, models.model),
DetailTone::Muted,
)
.with_note("conversation model"),
};
}
match definition.model_policy().as_ref() {
ModelPolicy::Inherit => DetailField::new("Model", "inherit", DetailTone::Muted),
ModelPolicy::Prefer(selection) => {
DetailField::new("Model", model_name(selection), DetailTone::Normal).with_note("prefer")
}
ModelPolicy::Require(selection) => {
DetailField::new("Model", model_name(selection), DetailTone::Normal)
.with_note("require")
}
ModelPolicy::Select(selection) => {
DetailField::new("Model", model_name(selection), DetailTone::Normal)
}
}
}
fn agent_reasoning_field(entry: &AgentCatalogEntry, models: &AgentModelView<'_>) -> DetailField {
let definition = &entry.definition;
let configured = (entry.metadata.origin == AgentOrigin::Internal)
.then(|| models.internal_agents.get(definition.id.as_str()))
.flatten();
let level = match configured {
Some(selection) => {
Some(effective_internal_agent_reasoning(definition.id.as_str(), selection).to_string())
}
None => definition.reasoning().map(|level| level.to_string()),
};
match level {
Some(level) => DetailField::new("Reasoning", level, DetailTone::Normal),
None => DetailField::new("Reasoning", "inherit", DetailTone::Muted),
}
}
fn agent_tools_field(definition: &crate::agent::AgentDefinition) -> DetailField {
let summary = definition.tools_summary();
let tone = if summary == "none" {
DetailTone::Muted
} else {
DetailTone::Normal
};
DetailField::new("Tools", summary, tone)
}
fn agent_source_field(entry: &AgentCatalogEntry) -> DetailField {
match entry.metadata.path.as_deref() {
Some(path) => {
DetailField::new("Source", crate::paths::display(path), DetailTone::Normal).keep_end()
}
None => DetailField::new("Source", "embedded in rho", DetailTone::Muted),
}
}
fn agent_prompt_blocks(
definition: &crate::agent::AgentDefinition,
access: AgentAccess,
) -> Vec<DetailBlock> {
let (policy, text) = prompt_policy_parts(&definition.prompt);
let status = match text.lines().count() {
0 => policy.to_string(),
1 => format!("{policy} · 1 line"),
lines => format!("{policy} · {lines} lines"),
};
let mut blocks = vec![DetailBlock::Heading {
label: "PROMPT".into(),
status,
}];
if text.trim().is_empty() {
blocks.push(DetailBlock::Muted("(no prompt body)".into()));
return blocks;
}
blocks.push(match access {
AgentAccess::Internal => DetailBlock::Muted(text.to_string()),
AgentAccess::Editable | AgentAccess::ReadOnly => DetailBlock::Excerpt {
text: text.to_string(),
rows: PROMPT_EXCERPT_ROWS,
anchor: ExcerptAnchor::Start,
},
});
blocks
}
pub(super) fn prompt_policy_parts(prompt: &PromptPolicy) -> (&'static str, &str) {
match prompt {
PromptPolicy::Extend(text) => ("extends system prompt", text),
PromptPolicy::Replace(text) => ("replaces system prompt", text),
}
}
fn claude_code_rows_for(id: &str) -> ClaudeCodeRows {
if internal_agent_accepts_claude_runtime(id)
&& crate::claude_runtime::executable::resolve().is_ok()
{
ClaudeCodeRows::Offered
} else {
ClaudeCodeRows::Omitted
}
}
fn model_name(selection: &ModelSelection) -> String {
selection
.provider
.as_ref()
.map(|provider| rho_providers::provider::model_reference(provider, &selection.model))
.unwrap_or_else(|| selection.model.clone())
}
impl super::App {
pub(super) fn internal_agent_model_picker(
&mut self,
id: &str,
origin: InternalAgentModelPickerOrigin,
) -> UiPicker {
self.refresh_available_auths();
let current = self.internal_agent_picker_model(id);
let scope = self.resolved_model_picker_scope();
let picker = super::model_picker::internal_agent_model_picker(
super::model_picker::InternalAgentPickerInputs {
agent_id: id,
current: current.current,
conversation_model: current.conversation_model,
claude_code: claude_code_rows_for(id),
favorite_models: &self.info.runtime.favorite_models,
available_auths: &self.available_auths,
scope,
keybindings: &self.info.runtime.keybindings,
},
);
self.internal_agent_model_target = Some(InternalAgentModelTarget {
id: id.to_string(),
origin,
});
picker
}
fn internal_agent_picker_model(&self, id: &str) -> InternalAgentPickerModel {
let requires_model = internal_agent_requires_model(id);
match self.info.runtime.internal_agents.get(id) {
Some(selection) => InternalAgentPickerModel {
current: match &selection.target {
InternalAgentTarget::Rho(rho) => InternalAgentSelection::RhoModel {
provider: rho.provider.clone(),
model: rho.model.clone(),
},
InternalAgentTarget::ClaudeCli { model } => {
InternalAgentSelection::ClaudeCode {
model: model.clone(),
}
}
},
conversation_model: if requires_model {
ConversationModelRow::Omitted
} else {
ConversationModelRow::Offered { selected: false }
},
},
None if requires_model => InternalAgentPickerModel {
current: InternalAgentSelection::Unset,
conversation_model: ConversationModelRow::Omitted,
},
None => InternalAgentPickerModel {
current: InternalAgentSelection::RhoModel {
provider: self.info.runtime.provider.clone(),
model: self.info.runtime.model.clone(),
},
conversation_model: ConversationModelRow::Offered { selected: true },
},
}
}
pub(super) fn open_internal_agent_model_picker(
&mut self,
id: &str,
origin: InternalAgentModelPickerOrigin,
) -> bool {
let picker = self.internal_agent_model_picker(id, origin);
if picker.items.is_empty() {
self.internal_agent_model_target = None;
self.report_missing_cached_provider_models();
return false;
}
if origin.opens_standalone() {
self.input_ui.set_composer(ComposerMode::Picker(picker));
} else {
self.open_child_picker(picker)
}
true
}
pub(super) fn open_selected_internal_agent_model_picker(&mut self, id: &str) -> bool {
let internal = crate::agent::internal_definitions()
.iter()
.any(|definition| definition.id.as_str() == id);
if internal {
self.open_internal_agent_model_picker(id, InternalAgentModelPickerOrigin::AgentsPicker);
}
internal
}
pub(super) fn execute_agents_command(&mut self) -> anyhow::Result<()> {
let catalog = match AgentCatalog::discover(&self.info.runtime.cwd) {
Ok(catalog) => catalog,
Err(error) => {
self.insert_entry(&super::Entry::Error(format!(
"could not reload agents: {error}"
)));
self.input_ui.set_composer(super::ComposerMode::Input);
self.set_status("agent reload failed");
return Ok(());
}
};
let mut picker = agent_picker(catalog, AgentModelView::from(&self.info.runtime));
if let Some(target) = self.internal_agent_model_target.as_ref() {
Self::restore_picker_position(&mut picker, &target.id, String::new());
}
self.input_ui
.set_composer(super::ComposerMode::Picker(picker));
self.set_status("loaded agents");
Ok(())
}
}
#[cfg(test)]
#[path = "agent_picker_tests.rs"]
mod tests;