#![allow(dead_code)]
use std::cell::RefCell;
use std::path::PathBuf;
use std::rc::Rc;
use codewhale_command_contract::facets::{
CommandCostContext, CommandModePolicyContext, CommandModelContext, CommandSessionContext,
CommandSkillsContext, CommandSystemPromptContext, CommandWorkspaceContext,
};
use codewhale_command_contract::handler::{CommandContexts, ContextParts};
use codewhale_command_contract::types::{
CommandApprovalMode, CommandCurrency, CommandMode, CommandProviderId, CommandReasoningEffort,
};
use codewhale_config::AppMode;
use codewhale_core::request::{Message, SystemPrompt};
use codewhale_execpolicy::ApprovalMode;
use crate::localization::MessageId;
use crate::pricing::CostCurrency;
use crate::tui::app::{App, ReasoningEffort};
pub(crate) const PENDING_GROUPS: &[&str] = &[
"config", "core", "debug", "memory", "plugins", "project", "session", "skills", "utility",
];
pub(crate) fn to_command_mode(mode: AppMode) -> CommandMode {
match mode {
AppMode::Agent => CommandMode::Agent,
AppMode::Auto => CommandMode::Auto,
AppMode::Yolo => CommandMode::Yolo,
AppMode::Plan => CommandMode::Plan,
AppMode::Operate => CommandMode::Operate,
}
}
fn from_command_mode(mode: CommandMode) -> AppMode {
match mode {
CommandMode::Agent => AppMode::Agent,
CommandMode::Auto => AppMode::Auto,
CommandMode::Yolo => AppMode::Yolo,
CommandMode::Plan => AppMode::Plan,
CommandMode::Operate => AppMode::Operate,
}
}
pub(crate) fn to_command_approval(mode: ApprovalMode) -> CommandApprovalMode {
match mode {
ApprovalMode::Auto => CommandApprovalMode::Auto,
ApprovalMode::Bypass => CommandApprovalMode::Bypass,
ApprovalMode::Suggest => CommandApprovalMode::Suggest,
ApprovalMode::Never => CommandApprovalMode::Never,
}
}
pub(crate) fn to_command_effort(effort: ReasoningEffort) -> CommandReasoningEffort {
match effort {
ReasoningEffort::Off => CommandReasoningEffort::Off,
ReasoningEffort::Minimal => CommandReasoningEffort::Minimal,
ReasoningEffort::Low => CommandReasoningEffort::Low,
ReasoningEffort::Medium => CommandReasoningEffort::Medium,
ReasoningEffort::High => CommandReasoningEffort::High,
ReasoningEffort::XHigh => CommandReasoningEffort::XHigh,
ReasoningEffort::Ultra => CommandReasoningEffort::Ultra,
ReasoningEffort::Auto => CommandReasoningEffort::Auto,
ReasoningEffort::Max => CommandReasoningEffort::Max,
}
}
pub(crate) fn to_command_currency(currency: CostCurrency) -> CommandCurrency {
match currency {
CostCurrency::Usd => CommandCurrency::Usd,
CostCurrency::Cny => CommandCurrency::Cny,
}
}
fn from_command_currency(currency: CommandCurrency) -> CostCurrency {
match currency {
CommandCurrency::Usd => CostCurrency::Usd,
CommandCurrency::Cny => CostCurrency::Cny,
}
}
pub(crate) fn to_provider_id(identity: &str) -> CommandProviderId {
CommandProviderId(identity.to_string())
}
pub(crate) fn key_to_message_id(key: &'static str) -> Option<MessageId> {
Some(match key {
"cmd_advisor_description" => MessageId::CmdAdvisorDescription,
"cmd_agent_description" => MessageId::CmdAgentDescription,
"cmd_anchor_description" => MessageId::CmdAnchorDescription,
"cmd_attach_description" => MessageId::CmdAttachDescription,
"cmd_auto_description" => MessageId::CmdAutoDescription,
"cmd_auth_description" => MessageId::CmdAuthDescription,
"cmd_automation_description" => MessageId::CmdAutomationDescription,
"cmd_balance_description" => MessageId::CmdBalanceDescription,
"cmd_branch_description" => MessageId::CmdBranchDescription,
"cmd_cache_description" => MessageId::CmdCacheDescription,
"cmd_change_description" => MessageId::CmdChangeDescription,
"cmd_clear_description" => MessageId::CmdClearDescription,
"cmd_compact_description" => MessageId::CmdCompactDescription,
"cmd_config_description" => MessageId::CmdConfigDescription,
"cmd_constitution_description" => MessageId::CmdConstitutionDescription,
"cmd_context_description" => MessageId::CmdContextDescription,
"cmd_cost_description" => MessageId::CmdCostDescription,
"cmd_diff_description" => MessageId::CmdDiffDescription,
"cmd_edit_description" => MessageId::CmdEditDescription,
"cmd_effort_description" => MessageId::CmdEffortDescription,
"cmd_exit_description" => MessageId::CmdExitDescription,
"cmd_export_description" => MessageId::CmdExportDescription,
"cmd_feedback_description" => MessageId::CmdFeedbackDescription,
"cmd_fleet_description" => MessageId::CmdFleetDescription,
"cmd_fork_description" => MessageId::CmdForkDescription,
"cmd_goal_description" => MessageId::CmdGoalDescription,
"cmd_help_description" => MessageId::CmdHelpDescription,
"cmd_hf_description" => MessageId::CmdHfDescription,
"cmd_home_description" => MessageId::CmdHomeDescription,
"cmd_hooks_description" => MessageId::CmdHooksDescription,
"cmd_hotbar_description" => MessageId::CmdHotbarDescription,
"cmd_init_description" => MessageId::CmdInitDescription,
"cmd_jobs_description" => MessageId::CmdJobsDescription,
"cmd_lane_description" => MessageId::CmdLaneDescription,
"cmd_links_description" => MessageId::CmdLinksDescription,
"cmd_load_description" => MessageId::CmdLoadDescription,
"cmd_logout_description" => MessageId::CmdLogoutDescription,
"cmd_lsp_description" => MessageId::CmdLspDescription,
"cmd_mcp_description" => MessageId::CmdMcpDescription,
"cmd_memory_description" => MessageId::CmdMemoryDescription,
"cmd_mode_description" => MessageId::CmdModeDescription,
"cmd_model_db_description" => MessageId::CmdModelDbDescription,
"cmd_model_description" => MessageId::CmdModelDescription,
"cmd_models_description" => MessageId::CmdModelsDescription,
"cmd_network_description" => MessageId::CmdNetworkDescription,
"cmd_new_description" => MessageId::CmdNewDescription,
"cmd_note_description" => MessageId::CmdNoteDescription,
"cmd_permissions_description" => MessageId::CmdPermissionsDescription,
"cmd_pin_description" => MessageId::CmdPinDescription,
"cmd_plugin_description" => MessageId::CmdPluginDescription,
"cmd_plugin_detail_description" => MessageId::CmdPluginDetailDescription,
"cmd_preview_request_description" => MessageId::CmdPreviewRequestDescription,
"cmd_profile_description" => MessageId::CmdProfileDescription,
"cmd_provider_description" => MessageId::CmdProviderDescription,
"cmd_purge_description" => MessageId::CmdPurgeDescription,
"cmd_queue_description" => MessageId::CmdQueueDescription,
"cmd_relay_description" => MessageId::CmdRelayDescription,
"cmd_remote_control_description" => MessageId::CmdRemoteControlDescription,
"cmd_remote_env_description" => MessageId::CmdRemoteEnvDescription,
"cmd_rename_description" => MessageId::CmdRenameDescription,
"cmd_restore_description" => MessageId::CmdRestoreDescription,
"cmd_resume_description" => MessageId::CmdResumeDescription,
"cmd_retry_description" => MessageId::CmdRetryDescription,
"cmd_review_description" => MessageId::CmdReviewDescription,
"cmd_rlm_description" => MessageId::CmdRlmDescription,
"cmd_save_description" => MessageId::CmdSaveDescription,
"cmd_sessions_description" => MessageId::CmdSessionsDescription,
"cmd_settings_description" => MessageId::CmdSettingsDescription,
"cmd_setup_description" => MessageId::CmdSetupDescription,
"cmd_share_description" => MessageId::CmdShareDescription,
"cmd_sidebar_description" => MessageId::CmdSidebarDescription,
"cmd_skill_description" => MessageId::CmdSkillDescription,
"cmd_skills_description" => MessageId::CmdSkillsDescription,
"cmd_stash_description" => MessageId::CmdStashDescription,
"cmd_status_description" => MessageId::CmdStatusDescription,
"cmd_statusline_description" => MessageId::CmdStatuslineDescription,
"cmd_structcopy_description" => MessageId::CmdStructcopyDescription,
"cmd_subagents_description" => MessageId::CmdSubagentsDescription,
"cmd_system_description" => MessageId::CmdSystemDescription,
"cmd_task_description" => MessageId::CmdTaskDescription,
"cmd_theme_description" => MessageId::CmdThemeDescription,
"cmd_title_description" => MessageId::CmdTitleDescription,
"cmd_tokens_description" => MessageId::CmdTokensDescription,
"cmd_tools_description" => MessageId::CmdToolsDescription,
"cmd_translate_description" => MessageId::CmdTranslateDescription,
"cmd_tree_description" => MessageId::CmdTreeDescription,
"cmd_trust_description" => MessageId::CmdTrustDescription,
"cmd_turn_inspect_description" => MessageId::CmdTurnInspectDescription,
"cmd_undo_description" => MessageId::CmdUndoDescription,
"cmd_update_description" => MessageId::CmdUpdateDescription,
"cmd_verbose_description" => MessageId::CmdVerboseDescription,
"cmd_voice_control_description" => MessageId::CmdVoiceControlDescription,
"cmd_voice_description" => MessageId::CmdVoiceDescription,
"cmd_voice_send_description" => MessageId::CmdVoiceSendDescription,
"cmd_workflow_description" => MessageId::CmdWorkflowDescription,
"cmd_workflows_description" => MessageId::CmdWorkflowsDescription,
"cmd_workspace_description" => MessageId::CmdWorkspaceDescription,
_ => return None,
})
}
struct CommandHost<'a> {
app: RefCell<&'a mut App>,
}
type SharedCommandHost<'a> = Rc<CommandHost<'a>>;
pub(crate) struct SessionAdapter<'a> {
host: SharedCommandHost<'a>,
}
impl CommandSessionContext for SessionAdapter<'_> {
fn session_id(&self) -> Option<String> {
self.host.app.borrow().current_session_id.clone()
}
fn api_messages(&self) -> Vec<Message> {
self.host.app.borrow().api_messages.clone()
}
fn add_message(&mut self, message: Message) {
self.host.app.borrow_mut().api_messages.push(message);
}
fn queued_message_count(&self) -> usize {
self.host.app.borrow().queued_message_count()
}
fn remove_queued_message(&mut self, index: usize) -> Result<(), String> {
self.host
.app
.borrow_mut()
.remove_queued_message(index)
.map(|_| ())
.ok_or_else(|| format!("queued message index {index} out of bounds"))
}
fn total_tokens(&self) -> u64 {
u64::from(self.host.app.borrow().session.total_tokens)
}
}
pub(crate) struct ModelAdapter<'a> {
host: SharedCommandHost<'a>,
}
impl CommandModelContext for ModelAdapter<'_> {
fn current_model(&self) -> String {
self.host.app.borrow().model.clone()
}
fn auto_model(&self) -> bool {
self.host.app.borrow().auto_model
}
fn set_model_selection(&mut self, model: String, provider: Option<CommandProviderId>) {
let mut app = self.host.app.borrow_mut();
if let Some(provider) = provider {
let identity = provider.0;
let provider = crate::config::ApiProvider::parse(&identity)
.unwrap_or(crate::config::ApiProvider::Custom);
app.set_provider_identity(provider, identity);
}
app.set_model_selection(model);
}
fn reasoning_effort(&self) -> CommandReasoningEffort {
to_command_effort(self.host.app.borrow().reasoning_effort)
}
fn provider_identity(&self) -> Option<CommandProviderId> {
let app = self.host.app.borrow();
let identity = app.provider_identity_for_persistence();
(!identity.trim().is_empty()).then(|| to_provider_id(identity))
}
fn fallback_chain(&self) -> Vec<CommandProviderId> {
self.host
.app
.borrow()
.fallback_chain_entries()
.into_iter()
.map(|(_, provider, _)| to_provider_id(provider.as_str()))
.collect()
}
}
pub(crate) struct CostAdapter<'a> {
host: SharedCommandHost<'a>,
}
fn command_cost_estimate(amount: f64, currency: CommandCurrency) -> crate::pricing::CostEstimate {
match currency {
CommandCurrency::Usd => crate::pricing::CostEstimate {
usd: amount,
cny: 0.0,
},
CommandCurrency::Cny => crate::pricing::CostEstimate {
usd: 0.0,
cny: amount,
},
}
}
impl CommandCostContext for CostAdapter<'_> {
fn display_currency(&self) -> CommandCurrency {
let app = self.host.app.borrow();
to_command_currency(app.cost_display_currency(app.cost_currency))
}
fn session_cost_for_currency(&self, currency: CommandCurrency) -> f64 {
self.host
.app
.borrow()
.session_cost_for_currency(from_command_currency(currency))
}
fn subagent_cost_for_currency(&self, currency: CommandCurrency) -> f64 {
self.host
.app
.borrow()
.subagent_cost_for_currency(from_command_currency(currency))
}
fn accrue_cost_estimate(&mut self, amount: f64, currency: CommandCurrency) {
self.host
.app
.borrow_mut()
.accrue_session_cost_estimate(command_cost_estimate(amount, currency));
}
fn record_turn_cost(
&mut self,
amount: f64,
currency: CommandCurrency,
route_receipt: Option<String>,
) {
let mut app = self.host.app.borrow_mut();
app.accrue_session_cost_estimate(command_cost_estimate(amount, currency));
if let Some(receipt) = route_receipt {
app.record_turn_cost_route_receipt(receipt);
}
}
}
pub(crate) struct ModePolicyAdapter<'a> {
host: SharedCommandHost<'a>,
}
impl CommandModePolicyContext for ModePolicyAdapter<'_> {
fn mode(&self) -> CommandMode {
to_command_mode(self.host.app.borrow().mode)
}
fn set_mode(&mut self, mode: CommandMode) {
self.host.app.borrow_mut().set_mode(from_command_mode(mode));
}
fn approval_mode(&self) -> CommandApprovalMode {
to_command_approval(self.host.app.borrow().approval_mode)
}
fn allow_shell(&self) -> bool {
self.host.app.borrow().allow_shell
}
fn set_shell_access(&mut self, allow: bool) {
self.host.app.borrow_mut().set_agent_shell_access(allow);
}
fn policy_locked(&self) -> bool {
self.host.app.borrow().approval_policy_locked()
}
}
pub(crate) struct SystemPromptAdapter<'a> {
host: SharedCommandHost<'a>,
}
impl CommandSystemPromptContext for SystemPromptAdapter<'_> {
fn system_prompt(&self) -> Option<SystemPrompt> {
self.host.app.borrow().system_prompt.clone()
}
}
pub(crate) struct SkillsAdapter<'a> {
host: SharedCommandHost<'a>,
}
impl CommandSkillsContext for SkillsAdapter<'_> {
fn active_skill(&self) -> Option<String> {
self.host.app.borrow().active_skill.clone()
}
fn active_skill_provenance(&self) -> Option<String> {
self.host
.app
.borrow()
.active_skill_provenance
.as_ref()
.map(|authority| authority.plugin_name.clone())
}
fn refresh_skill_cache(&mut self) {
self.host.app.borrow_mut().refresh_skill_cache();
}
}
pub(crate) struct WorkspaceAdapter<'a> {
host: SharedCommandHost<'a>,
}
impl CommandWorkspaceContext for WorkspaceAdapter<'_> {
fn workspace(&self) -> PathBuf {
self.host.app.borrow().workspace.clone()
}
fn work_state_snapshot(&self) -> Result<Option<String>, String> {
self.host.app.borrow().work_state_snapshot().map(|state| {
state.and_then(|state| crate::todo_snapshot::todo_snapshot_body(&state.todos))
})
}
}
pub(crate) struct CommandContextBundle<'a> {
session: SessionAdapter<'a>,
model: ModelAdapter<'a>,
cost: CostAdapter<'a>,
mode_policy: ModePolicyAdapter<'a>,
system_prompt: SystemPromptAdapter<'a>,
skills: SkillsAdapter<'a>,
workspace: WorkspaceAdapter<'a>,
}
impl<'a> CommandContextBundle<'a> {
pub(crate) fn contexts(&mut self) -> CommandContexts<'_> {
CommandContexts::empty()
.with_session(&mut self.session)
.with_model(&mut self.model)
.with_cost(&mut self.cost)
.with_mode_policy(&mut self.mode_policy)
.with_system_prompt(&mut self.system_prompt)
.with_skills(&mut self.skills)
.with_workspace(&mut self.workspace)
}
pub(crate) fn parts(&mut self) -> ContextParts<'_> {
self.contexts().into_parts()
}
}
impl App {
pub(crate) fn command_contexts(&mut self) -> CommandContextBundle<'_> {
let host = Rc::new(CommandHost {
app: RefCell::new(self),
});
CommandContextBundle {
session: SessionAdapter { host: host.clone() },
model: ModelAdapter { host: host.clone() },
cost: CostAdapter { host: host.clone() },
mode_policy: ModePolicyAdapter { host: host.clone() },
system_prompt: SystemPromptAdapter { host: host.clone() },
skills: SkillsAdapter { host: host.clone() },
workspace: WorkspaceAdapter { host },
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_app() -> App {
crate::test_support::test_app_with_options(crate::test_support::test_tui_options(
PathBuf::from("."),
))
}
#[test]
fn pending_groups_is_sorted_unique_and_matches_checked_in_frontier() {
let mut sorted = PENDING_GROUPS.to_vec();
sorted.sort_unstable();
assert_eq!(PENDING_GROUPS, sorted.as_slice(), "frontier must be sorted");
let unique: std::collections::BTreeSet<&str> = PENDING_GROUPS.iter().copied().collect();
assert_eq!(
unique.len(),
PENDING_GROUPS.len(),
"frontier must be unique"
);
let topology: serde_json::Value = serde_json::from_str(include_str!(
"../../../../scripts/command-migration-topology.json"
))
.expect("checked-in topology must be valid JSON");
let frontier = topology["frontier"]
.as_array()
.expect("topology frontier")
.iter()
.map(|entry| entry.as_str().expect("string frontier entry"))
.collect::<Vec<_>>();
assert_eq!(PENDING_GROUPS, frontier.as_slice());
}
#[test]
fn boundary_mappings_cover_every_variant() {
for mode in [
AppMode::Agent,
AppMode::Auto,
AppMode::Yolo,
AppMode::Plan,
AppMode::Operate,
] {
let command = to_command_mode(mode);
assert_eq!(from_command_mode(command), mode);
}
for approval in [
ApprovalMode::Auto,
ApprovalMode::Bypass,
ApprovalMode::Suggest,
ApprovalMode::Never,
] {
let _ = to_command_approval(approval);
}
for effort in [
ReasoningEffort::Off,
ReasoningEffort::Minimal,
ReasoningEffort::Low,
ReasoningEffort::Medium,
ReasoningEffort::High,
ReasoningEffort::XHigh,
ReasoningEffort::Ultra,
ReasoningEffort::Auto,
ReasoningEffort::Max,
] {
let _ = to_command_effort(effort);
}
for currency in [CostCurrency::Usd, CostCurrency::Cny] {
let command = to_command_currency(currency);
assert_eq!(from_command_currency(command), currency);
}
}
#[test]
fn key_to_message_id_resolves_convention_keys_and_rejects_unknown() {
assert_eq!(
key_to_message_id("cmd_balance_description"),
Some(MessageId::CmdBalanceDescription)
);
assert_eq!(
key_to_message_id("cmd_voice_control_description"),
Some(MessageId::CmdVoiceControlDescription)
);
assert_eq!(key_to_message_id("cmd_nonexistent_description"), None);
assert_eq!(key_to_message_id(""), None);
}
#[test]
fn cost_adapter_delegates_totals_high_water_and_route_receipt_to_app() {
let mut app = test_app();
app.cost_currency = CostCurrency::Usd;
{
let mut bundle = app.command_contexts();
let mut parts = bundle.parts();
let cost = parts.cost.as_mut().expect("cost facet");
cost.accrue_cost_estimate(3.0, CommandCurrency::Usd);
cost.record_turn_cost(
4.0,
CommandCurrency::Cny,
Some("provider=deepseek model=x".to_string()),
);
assert_eq!(cost.session_cost_for_currency(CommandCurrency::Usd), 3.0);
assert_eq!(cost.session_cost_for_currency(CommandCurrency::Cny), 4.0);
}
assert_eq!(app.session_cost_for_currency(CostCurrency::Usd), 3.0);
assert_eq!(app.session_cost_for_currency(CostCurrency::Cny), 4.0);
assert_eq!(
app.displayed_session_cost_for_currency(CostCurrency::Usd),
3.0
);
assert!(
app.session
.cost_route_receipts
.contains("provider=deepseek model=x")
);
}
#[test]
fn session_adapter_delegates_message_and_queue_operations_to_app() {
let mut app = test_app();
app.current_session_id = Some("s1".to_string());
app.session.total_tokens = 42;
app.queue_message(crate::tui::app::QueuedMessage {
display: "q".to_string(),
skill_instruction: None,
skill_provenance: None,
});
{
let mut bundle = app.command_contexts();
let mut parts = bundle.parts();
let session = parts.session.as_mut().expect("session facet");
assert_eq!(session.session_id().as_deref(), Some("s1"));
session.add_message(Message {
role: "user".to_string(),
content: vec![],
});
assert_eq!(session.api_messages().len(), 1);
assert_eq!(session.queued_message_count(), 1);
assert!(session.remove_queued_message(0).is_ok());
assert!(session.remove_queued_message(5).is_err());
assert_eq!(session.total_tokens(), 42);
}
assert_eq!(app.api_messages.len(), 1);
assert_eq!(app.queued_message_count(), 0);
}
#[test]
fn model_adapter_delegates_selection_and_route_invalidation_to_app() {
let mut app = test_app();
app.last_effective_model = Some("stale-model".to_string());
{
let mut bundle = app.command_contexts();
let mut parts = bundle.parts();
let model = parts.model.as_mut().expect("model facet");
model.set_model_selection("auto".to_string(), Some(to_provider_id("deepseek")));
assert!(model.auto_model());
assert_eq!(model.current_model(), "auto");
assert_eq!(
model.provider_identity().map(|id| id.0).as_deref(),
Some("deepseek")
);
}
assert!(app.last_effective_model.is_none());
assert_eq!(app.provider_identity_for_persistence(), "deepseek");
}
#[test]
fn mode_policy_adapter_delegates_mode_and_shell_policy_to_app() {
let mut app = test_app();
app.set_agent_shell_access(false);
{
let mut bundle = app.command_contexts();
let mut parts = bundle.parts();
let policy = parts.mode_policy.as_mut().expect("mode facet");
policy.set_shell_access(true);
policy.set_mode(CommandMode::Yolo);
assert!(policy.allow_shell());
assert_eq!(policy.approval_mode(), CommandApprovalMode::Bypass);
}
assert_eq!(
app.mode,
AppMode::Agent,
"YOLO is an Agent compatibility mode"
);
assert!(app.yolo);
assert!(app.allow_shell);
}
#[test]
fn system_prompt_adapter_returns_owned_prompt() {
let mut app = test_app();
app.system_prompt = Some(SystemPrompt::Text("system".to_string()));
let mut bundle = app.command_contexts();
let parts = bundle.parts();
assert!(
parts
.system_prompt
.expect("system prompt facet")
.system_prompt()
.is_some()
);
}
#[test]
fn workspace_adapter_returns_path_and_snapshot() {
let mut app = test_app();
let expected = app.workspace.clone();
let mut bundle = app.command_contexts();
let parts = bundle.parts();
let workspace = parts.workspace.expect("workspace facet");
assert_eq!(workspace.workspace(), expected);
assert!(workspace.work_state_snapshot().is_ok());
}
#[test]
fn envelope_exposes_all_seven_facets_without_app_in_handler_surface() {
let mut app = test_app();
let mut bundle = app.command_contexts();
let parts = bundle.parts();
assert!(parts.session.is_some());
assert!(parts.model.is_some());
assert!(parts.cost.is_some());
assert!(parts.mode_policy.is_some());
assert!(parts.system_prompt.is_some());
assert!(parts.skills.is_some());
assert!(parts.workspace.is_some());
}
}