use crate::{
agent::{
effective_internal_agent_reasoning, internal_agent_accepts_claude_runtime,
internal_agent_requires_model, AgentCatalog, AgentCatalogEntry, AgentOrigin,
AgentRuntimeSpec, ModelPolicy, ModelSelection, PromptPolicy,
},
app::interactive_runtime::InteractiveRuntime,
config::{InternalAgentModelConfig, InternalAgentTarget},
};
use super::{
model_picker::{ClaudeCodeRows, FallbackModelRow, 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,
AdvisorModelCommand,
AdvisorConfigRow,
AdvisorModelConfigRow,
PermissionModeConfigRow,
PermissionModeCommand,
PermissionClassifierModelConfigRow,
PermissionModeStartup,
PermissionScreenModelConfigRow,
PermissionScreenSetupConfigRow,
PermissionScreenSetupCommand,
}
impl InternalAgentModelPickerOrigin {
pub(super) fn opens_standalone(self) -> bool {
matches!(
self,
Self::AdvisorCommand
| Self::AdvisorModelCommand
| Self::PermissionModeStartup
| Self::PermissionModeCommand
| Self::PermissionScreenSetupCommand
)
}
}
#[derive(Clone, Debug)]
pub(super) enum AgentModelDefault {
Internal,
Definition(ModelPolicy),
}
#[derive(Clone, Debug)]
pub(super) struct AgentModelTarget {
pub(super) id: String,
pub(super) origin: InternalAgentModelPickerOrigin,
pub(super) default: AgentModelDefault,
}
struct AgentPickerModel {
current: InternalAgentSelection,
fallback: FallbackModelRow,
}
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_restore_status("loaded agents")
.with_key_hints(super::PickerKeyHints {
row_delete: super::RowDeleteKeys::DeleteOnly,
..Default::default()
})
.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)]
pub(super) enum AgentAccess {
Editable,
Internal,
ModelOnly,
ReadOnly,
}
impl AgentAccess {
pub(super) fn of(entry: &AgentCatalogEntry) -> Self {
match entry.metadata.origin {
AgentOrigin::RhoHome | AgentOrigin::Project => Self::Editable,
AgentOrigin::Internal => Self::Internal,
AgentOrigin::UserPlugin | AgentOrigin::ProjectPlugin => {
match entry.definition.runtime {
AgentRuntimeSpec::Rho { .. } => Self::ModelOnly,
AgentRuntimeSpec::ClaudeCli(_)
| AgentRuntimeSpec::Cursor(_)
| AgentRuntimeSpec::Antigravity(_) => Self::ReadOnly,
}
}
AgentOrigin::BuiltIn | AgentOrigin::AgentsHome | AgentOrigin::Workflow => {
Self::ReadOnly
}
}
}
fn selection_verb(self) -> &'static str {
match self {
Self::Editable => "edit",
Self::Internal | Self::ModelOnly => "configure",
Self::ReadOnly => "view prompt",
}
}
fn tone(self) -> PickerBadgeTone {
match self {
Self::Editable => PickerBadgeTone::Editable,
Self::Internal | Self::ModelOnly => 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::ModelOnly => "● model only",
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::UserPlugin | AgentOrigin::ProjectPlugin => (3, "PLUGIN"),
AgentOrigin::BuiltIn => (4, "BUILT IN"),
AgentOrigin::Internal => (5, "INTERNAL"),
}
}
fn agent_item(entry: &AgentCatalogEntry, models: &AgentModelView<'_>) -> PickerItem {
let definition = &entry.definition;
let access = AgentAccess::of(entry);
PickerItem {
section: Some(agent_section(entry.metadata.origin).1.into()),
detail: Some(agent_detail(entry, access, models).into()),
badge: Some(access.marker()),
selection_verb: Some(access.selection_verb()),
..PickerItem::new(definition.id.to_string(), definition.id.to_string())
}
}
fn agent_detail(
entry: &AgentCatalogEntry,
access: AgentAccess,
models: &AgentModelView<'_>,
) -> DetailSheet {
let definition = &entry.definition;
let ModelFields {
model,
reasoning,
fast,
} = agent_model_fields(entry, models);
let mut fields = vec![
DetailField::new(
"Runtime",
definition.runtime.runtime().to_string(),
DetailTone::Normal,
),
model,
reasoning,
agent_tools_field(definition),
];
if fast {
fields.push(DetailField::new("Fast", "on", DetailTone::Normal));
}
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 }
}
struct ModelFields {
model: DetailField,
reasoning: DetailField,
fast: bool,
}
fn agent_model_fields(entry: &AgentCatalogEntry, models: &AgentModelView<'_>) -> ModelFields {
let definition = &entry.definition;
if entry.metadata.origin == AgentOrigin::Internal {
return internal_agent_model_fields(entry, models);
}
let file_policy = definition.model_policy();
let configured = match crate::agent::configured_model(definition, models.internal_agents) {
Ok(configured) => configured,
Err(_) => {
let reference = models
.internal_agents
.get(definition.id.as_str())
.map_or_else(String::new, InternalAgentModelConfig::display_reference);
return ModelFields {
model: DetailField::new("Model", reference, DetailTone::Warning)
.with_note(format!("[internal_agents.{}] cannot apply", definition.id)),
reasoning: reasoning_field(definition.reasoning()),
fast: definition.fast(),
};
}
};
let (policy, reasoning) = crate::agent::effective_rho_model(
&file_policy,
definition.reasoning(),
configured.as_ref(),
);
let model = model_policy_field(policy);
ModelFields {
model: match (configured.is_some(), entry.metadata.origin.is_plugin()) {
(true, true) => model.with_note("override"),
(true, false) => {
model.with_note(format!("override from [internal_agents.{}]", definition.id))
}
(false, _) => model,
},
reasoning: reasoning_field(reasoning),
fast: policy.selection().is_some_and(|selection| selection.fast),
}
}
fn internal_agent_model_fields(
entry: &AgentCatalogEntry,
models: &AgentModelView<'_>,
) -> ModelFields {
let definition = &entry.definition;
let id = definition.id.as_str();
let configured = models.internal_agents.get(id);
let model = match configured {
Some(selection) => {
DetailField::new("Model", selection.display_reference(), DetailTone::Normal)
.with_note("override")
}
None if internal_agent_requires_model(id) => {
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"),
};
let reasoning = match configured {
Some(selection) => Some(effective_internal_agent_reasoning(id, selection)),
None => definition.reasoning(),
};
ModelFields {
model,
reasoning: reasoning_field(reasoning),
fast: definition.fast(),
}
}
fn model_policy_field(policy: &ModelPolicy) -> DetailField {
match policy {
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 reasoning_field(level: Option<crate::agent::ReasoningLevel>) -> DetailField {
match level {
Some(level) => DetailField::new("Reasoning", level.to_string(), 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 | AgentAccess::ModelOnly => 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())
}
fn configured_selection(selection: &InternalAgentModelConfig) -> InternalAgentSelection {
match &selection.target {
InternalAgentTarget::Rho(rho) => InternalAgentSelection::RhoModel {
provider: rho.provider.clone(),
model: rho.model.clone(),
},
InternalAgentTarget::ClaudeCli { model } => InternalAgentSelection::ClaudeCode {
model: model.clone(),
},
}
}
impl super::App {
pub(super) fn internal_agent_model_picker(&mut self, target: AgentModelTarget) -> UiPicker {
self.refresh_available_auths();
let id = target.id.as_str();
let current = match &target.default {
AgentModelDefault::Internal => self.internal_agent_picker_model(id),
AgentModelDefault::Definition(model) => self.catalog_agent_picker_model(id, model),
};
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,
fallback: current.fallback,
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(target);
picker
}
fn internal_agent_picker_model(&self, id: &str) -> AgentPickerModel {
let requires_model = internal_agent_requires_model(id);
match self.info.runtime.internal_agents.get(id) {
Some(selection) => AgentPickerModel {
current: configured_selection(selection),
fallback: if requires_model {
FallbackModelRow::Omitted
} else {
FallbackModelRow::Conversation { selected: false }
},
},
None if requires_model => AgentPickerModel {
current: InternalAgentSelection::Unset,
fallback: FallbackModelRow::Omitted,
},
None => AgentPickerModel {
current: InternalAgentSelection::RhoModel {
provider: self.info.runtime.provider.clone(),
model: self.info.runtime.model.clone(),
},
fallback: FallbackModelRow::Conversation { selected: true },
},
}
}
fn catalog_agent_picker_model(&self, id: &str, model: &ModelPolicy) -> AgentPickerModel {
let configured = self.info.runtime.internal_agents.get(id);
let selected = configured.is_none();
let (fallback, default_current) = match model.selection() {
None => (
FallbackModelRow::Conversation { selected },
InternalAgentSelection::RhoModel {
provider: self.info.runtime.provider.clone(),
model: self.info.runtime.model.clone(),
},
),
Some(selection) => (
FallbackModelRow::AgentDefault {
model: model_name(selection),
selected,
},
match &selection.provider {
Some(provider) => InternalAgentSelection::RhoModel {
provider: provider.clone(),
model: selection.model.clone(),
},
None => InternalAgentSelection::Unset,
},
),
};
AgentPickerModel {
current: configured.map_or(default_current, configured_selection),
fallback,
}
}
pub(super) fn open_internal_agent_model_picker(
&mut self,
id: &str,
origin: InternalAgentModelPickerOrigin,
) -> bool {
self.open_agent_model_picker(AgentModelTarget {
id: id.to_string(),
origin,
default: AgentModelDefault::Internal,
})
}
fn open_agent_model_picker(&mut self, target: AgentModelTarget) -> bool {
let origin = target.origin;
let picker = self.internal_agent_model_picker(target);
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::is_internal_agent_id(id);
if internal {
self.open_internal_agent_model_picker(id, InternalAgentModelPickerOrigin::AgentsPicker);
}
internal
}
pub(super) fn open_catalog_agent_model_picker(&mut self, entry: &AgentCatalogEntry) {
self.open_agent_model_picker(AgentModelTarget {
id: entry.definition.id.to_string(),
origin: InternalAgentModelPickerOrigin::AgentsPicker,
default: AgentModelDefault::Definition(entry.definition.model_policy().into_owned()),
});
}
pub(super) fn execute_agents_command(&mut self) -> anyhow::Result<()> {
if self.open_agents_picker() {
self.set_status("loaded agents");
}
Ok(())
}
pub(super) fn open_agents_picker(&mut self) -> bool {
let Some(catalog) = self.load_agents_or_report() else {
self.input_ui.set_composer(ComposerMode::Input);
return false;
};
let picker = self.agents_picker(catalog);
self.input_ui.set_composer(ComposerMode::Picker(picker));
true
}
pub(super) fn load_agents_or_report(&mut self) -> Option<AgentCatalog> {
match AgentCatalog::discover(&self.info.runtime.cwd) {
Ok(catalog) => {
for error in catalog.skipped() {
self.insert_entry(&super::Entry::Error(format!(
"could not load agent: {error}"
)));
}
Some(catalog)
}
Err(error) => {
self.insert_entry(&super::Entry::Error(format!(
"could not load agents: {error}"
)));
self.set_status("agent load failed");
None
}
}
}
fn agents_picker(&self, catalog: AgentCatalog) -> UiPicker {
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());
}
picker
}
pub(super) fn agents_picker_after_change(
&mut self,
agent: &mut InteractiveRuntime,
) -> Option<UiPicker> {
let Some(catalog) = self.load_agents_or_report() else {
self.input_ui.set_composer(ComposerMode::Input);
return None;
};
match agent.sync_agent_catalog(&catalog) {
Ok(Some(display)) => self.insert_entry(&super::Entry::Notice(display.into())),
Ok(None) => {}
Err(error) => self.insert_entry(&super::Entry::Error(format!(
"could not tell the model about the agent change: {error}"
))),
}
Some(self.agents_picker(catalog))
}
}
#[cfg(test)]
#[path = "agent_picker_tests.rs"]
mod tests;