rho-coding-agent 0.22.0

A lightweight agent harness inspired by Pi
Documentation
use super::{PickerAction, PickerBadge, PickerBadgeTone, PickerItem, TuiInfo, UiPicker};
use crate::model::{catalog, favorites};

pub(super) fn model_picker(info: &TuiInfo, available_auths: &[String]) -> UiPicker {
    model_picker_for_current(
        "select model",
        "type regex filter, ctrl-p pin/unpin, tab complete, up/down select, enter confirm, esc cancel",
        CurrentModel {
            provider: &info.provider,
            model: &info.model,
            badge: "selected",
        },
        &info.favorite_models,
        available_auths,
        PickerAction::SelectModel,
    )
}

pub(super) fn model_picker_during_run(
    info: &TuiInfo,
    pending: Option<&crate::model::catalog::ModelSelection>,
    available_auths: &[String],
) -> UiPicker {
    let (provider, model, badge) = pending
        .map(|selection| {
            (
                selection.provider.as_str(),
                selection.model.as_str(),
                "pending",
            )
        })
        .unwrap_or((&info.provider, &info.model, "selected"));
    model_picker_for_current(
        "select model for next turn",
        "current run keeps its model; selection applies after it fully ends, ctrl-p pin/unpin, enter confirm, esc cancel",
        CurrentModel {
            provider,
            model,
            badge,
        },
        &info.favorite_models,
        available_auths,
        PickerAction::SelectModel,
    )
}

pub(super) fn title_model_picker(
    current_provider: &str,
    current_model: &str,
    favorite_models: &[String],
    available_auths: &[String],
) -> UiPicker {
    model_picker_for_current(
        "select title model",
        "type regex filter, ctrl-p pin/unpin, tab complete, up/down select, enter confirm, esc cancel",
        CurrentModel {
            provider: current_provider,
            model: current_model,
            badge: "selected",
        },
        favorite_models,
        available_auths,
        PickerAction::SelectTitleModel,
    )
}

struct CurrentModel<'a> {
    provider: &'a str,
    model: &'a str,
    badge: &'a str,
}

fn model_picker_for_current(
    title: &str,
    help: &str,
    current: CurrentModel<'_>,
    favorite_models: &[String],
    available_auths: &[String],
    action: PickerAction,
) -> UiPicker {
    let CurrentModel {
        provider: current_provider,
        model: current_model,
        badge: selected_badge,
    } = current;
    let current = format!("{current_provider}/{current_model}");
    let favorites = favorites::normalized_favorite_models(favorite_models);
    let items = favorites::reorder_models_by_favorites(
        catalog::available_models_for_auths(available_auths),
        &favorites,
    )
    .into_iter()
    .map(|entry| {
        let value = format!("{}/{}", entry.provider, entry.model);
        let pinned = favorites
            .iter()
            .any(|favorite| favorite.matches(&entry.provider, &entry.model));
        let selected = entry.provider == current_provider && entry.model == current_model;
        let badge = match (pinned, selected) {
            (true, true) => Some(PickerBadge {
                text: format!("pinned, {selected_badge}"),
                tone: PickerBadgeTone::Selected,
            }),
            (true, false) => Some(PickerBadge {
                text: "pinned".into(),
                tone: PickerBadgeTone::Favorite,
            }),
            (false, true) => Some(PickerBadge {
                text: selected_badge.into(),
                tone: PickerBadgeTone::Selected,
            }),
            (false, false) => None,
        };
        PickerItem {
            label: value.clone(),
            detail: Some(if pinned {
                "Press Ctrl-P to unpin this model.".into()
            } else {
                "Press Ctrl-P to pin this model to the top of model pickers.".into()
            }),
            preview: None,
            badge,
            value,
        }
    })
    .collect::<Vec<_>>();

    let mut picker = UiPicker::new(title, help, items, action);
    if let Some(index) = picker.items.iter().position(|item| item.value == current) {
        picker.selected = index;
    }
    picker
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::app::config_repository::ConfigRepository;
    use crate::credentials::{available_auth_modes, save_codex_tokens, MemoryCredentialStore};

    #[test]
    fn model_picker_orders_pinned_models_before_selected_model() {
        let store = MemoryCredentialStore::default();
        save_codex_tokens(
            &store,
            &crate::credentials::CodexTokens {
                access_token: "access".into(),
                refresh_token: Some("refresh".into()),
                id_token: None,
                account_id: None,
            },
        )
        .unwrap();
        let auths = available_auth_modes(&store);
        let info = TuiInfo {
            cwd: std::path::PathBuf::from("/tmp/project"),
            provider: "openai-codex".into(),
            model: "gpt-5.6-sol".into(),
            reasoning: crate::reasoning::ReasoningLevel::Low,
            show_reasoning_output: true,
            auth: "codex".into(),
            title_provider: None,
            title_model: None,
            title_auth: None,
            favorite_models: vec!["openai-codex/gpt-5.4-mini".into()],
            questionnaire_enabled: true,
            session_id: None,
            recovered_messages: Vec::new(),
            open_resume_picker: false,
            config_repository: ConfigRepository::new(None),
            auth_unavailable: None,
            update_notice: None,
            herdr: crate::herdr::HerdrReporter::default(),
            max_tool_output_lines: 10,
        };

        let picker = model_picker(&info, &auths);

        assert_eq!(picker.items[0].value, "openai-codex/gpt-5.4-mini");
        assert_eq!(
            picker.selected_item().unwrap().value,
            "openai-codex/gpt-5.6-sol"
        );
    }

    #[test]
    fn running_model_picker_marks_pending_model_and_explains_timing() {
        let store = MemoryCredentialStore::default();
        save_codex_tokens(
            &store,
            &crate::credentials::CodexTokens {
                access_token: "access".into(),
                refresh_token: Some("refresh".into()),
                id_token: None,
                account_id: None,
            },
        )
        .unwrap();
        let auths = available_auth_modes(&store);
        let info = TuiInfo {
            cwd: std::path::PathBuf::from("/tmp/project"),
            provider: "openai-codex".into(),
            model: "gpt-5.5".into(),
            reasoning: crate::reasoning::ReasoningLevel::Low,
            show_reasoning_output: true,
            auth: "codex".into(),
            title_provider: None,
            title_model: None,
            title_auth: None,
            favorite_models: Vec::new(),
            questionnaire_enabled: true,
            session_id: None,
            recovered_messages: Vec::new(),
            open_resume_picker: false,
            config_repository: ConfigRepository::new(None),
            auth_unavailable: None,
            update_notice: None,
            herdr: crate::herdr::HerdrReporter::default(),
            max_tool_output_lines: 10,
        };
        let pending = crate::model::catalog::ModelSelection {
            provider: "openai-codex".into(),
            model: "gpt-5.4-mini".into(),
            auth: "codex".into(),
            from_catalog: true,
        };

        let picker = model_picker_during_run(&info, Some(&pending), &auths);

        assert_eq!(picker.title, "select model for next turn");
        assert!(picker.help.contains("after it fully ends"));
        let selected = picker.selected_item().unwrap();
        assert_eq!(selected.value, "openai-codex/gpt-5.4-mini");
        assert_eq!(selected.badge.as_ref().unwrap().text, "pending");
    }
}