polyc-tui 2026.9.0

Operator cockpit TUI (pc-tui) for the polychrome control plane: fleet, transcript, approvals, and tools panes.
//! Tools data adapter.
//!
//! The enabled-tool set for a conversation is `ConversationSpec.tools_enabled`
//! on the kube `Conversation` CR — that is the only read-only surface the
//! cockpit can reach without dialing the harness pod's in-process registry.
//!
//! Each enabled name is classified into a [`ToolSource`]: a *local* pure tool
//! (deterministic, runs in-process in the harness) or a *dynamic* tool served
//! by a remote Model Context Protocol source. The classification mirrors the
//! harness-side composite registry, where the in-process pure-tool catalogue is
//! a fixed, stable set and every other advertised name is contributed by a
//! dynamic source.
//!
//! `requires_approval` is gated harness-side by an operator env var on the pod
//! (a comma-separated allowlist), which the CR does not echo. This read-only
//! slice therefore leaves it `false`; a richer surface can fill it later.

use anyhow::Result;
use kube::Client;
use kube::api::Api;
use polyc_controller::Conversation;

use crate::components::tools::{ToolSource, ToolView};

/// Stable catalogue of in-process *local* tools the harness registry advertises
/// always-on (the coding core + the wallet/web proxy tools). Names not present
/// here are treated as [`ToolSource::Dynamic`] (contributed by a remote MCP
/// source). Kept in registry order.
///
/// Mirrors `polyc_tools::ToolRegistry`'s always-on set. Held as a local constant
/// rather than a dependency edge so the cockpit stays free of the harness's
/// transport/IAM stack — keep it in sync with `crates/tools/src/coding` +
/// `web::fetch_spec` + `paid_fetch::spec`.
pub(crate) const LOCAL_TOOLS: &[&str] = &[
    "shell_exec",
    "file_read",
    "file_write",
    "file_edit",
    "glob",
    "grep",
    "web_fetch",
    "paid_fetch",
];

/// Classify a tool name into its [`ToolSource`].
#[must_use]
pub(crate) fn classify_source(name: &str) -> ToolSource {
    if LOCAL_TOOLS.contains(&name) {
        ToolSource::Local
    } else {
        ToolSource::Dynamic
    }
}

/// Load the tool view for `conversation_id` from its `Conversation` CR spec.
///
/// # Errors
/// Returns an error if the kube `get` call fails (missing CR, RBAC, transport).
pub(crate) async fn load_tools(
    client: &Client,
    namespace: &str,
    conversation_id: &str,
) -> Result<Vec<ToolView>> {
    let api: Api<Conversation> = Api::namespaced(client.clone(), namespace);
    let conv = api.get(conversation_id).await?;
    Ok(views_from_spec(&conv))
}

/// Project a `Conversation`'s `spec.tools_enabled` into [`ToolView`] rows.
///
/// Each enabled name becomes one row, classified into a [`ToolSource`]. Rows
/// preserve the spec's declared order so the rendered table is stable.
#[must_use]
pub(crate) fn views_from_spec(conv: &Conversation) -> Vec<ToolView> {
    conv.spec
        .tools_enabled
        .iter()
        .map(|name| ToolView {
            name: name.clone(),
            source: classify_source(name),
            enabled: true,
            description: None,
            requires_approval: false,
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn local_tools_classify_as_local() {
        for name in LOCAL_TOOLS {
            assert_eq!(classify_source(name), ToolSource::Local, "{name}");
        }
        // The current coding core is classified Local (regression for the stale
        // demo-tool catalogue).
        assert_eq!(classify_source("shell_exec"), ToolSource::Local);
        assert_eq!(classify_source("web_fetch"), ToolSource::Local);
    }

    #[test]
    fn unknown_tools_classify_as_dynamic() {
        assert_eq!(classify_source("slacksearch"), ToolSource::Dynamic);
        assert_eq!(classify_source("websearch"), ToolSource::Dynamic);
        assert_eq!(classify_source(""), ToolSource::Dynamic);
    }
}