Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

facett-claude/src/lib.rs

//! **facett-claude** — the direct-Claude conversation pane as a facett-Elm component.
//!
//! nornir's 🤖 Claude tab (`src/viz/claude_tab.rs`) lived welded inside nornir, so no
//! other app could show a "chat with Claude" pane without duplicating it. This crate
//! extracts the drivable pane: user/assistant **turns**, a **prompt** input, a
//! **Send** action, and a **model** selector.
//!
//! ## The Elm seam (and why there is no `nornir` / `reqwest` dependency)
//!
//! Apps depend on this crate to render a Claude pane, so it must NOT depend on
//! `nornir` (cycle) and stays a pure leaf: it does NO HTTP itself. The Anthropic
//! call rides a [`ClaudeTransport`] trait (aliased [`ClaudeBackend`]) the **host**
//! wires — nornir's real `viz::claude::HttpTransport` at runtime, the built-in
//! [`EchoBackend`] mock/echo default otherwise (so the crate builds and every test
//! drives with **no network and no secrets**). [`ClaudePane::update`]
//! on `Send` emits a [`ClaudeEffect::Send`] (data); the host runs the transport
//! (see [`ClaudePane::deliver`]) and feeds the reply back as [`ClaudeMsg::ReceivedOk`]
//! / [`ClaudeMsg::ReceivedErr`]. No async, no network in the pane.
//!
//! It implements [`facett_core::Elm`]; [`facett_core::impl_facet_via_elm!`] bridges
//! it to [`facett_core::Facet`]/[`facett_core::Panel`], with `update_json` routed
//! into the Elm `update` — so the headless robot harness drives the whole
//! conversation through JSON and asserts `state_json`. One wire, every backend.

use egui::{Color32, Ui};
use facett_core::Elm;
use serde::{Deserialize, Serialize};

// The transport primitive + credential resolution live ONCE in the edda leaf
// `nornir-claude`; this pane CONSUMES it (L5) rather than twinning a bespoke
// Anthropic client. Re-exported so a host reaches the whole leaf — including the
// feature-gated live `HttpTransport` — through this crate: wrap any leaf transport
// in [`EddaBackend`] to plug it into the pane seam.
pub use nornir_claude;
/// The shared model list + credential/transport primitives, re-exported from the
/// `nornir-claude` leaf so this crate no longer twins them (`DEFAULT_MODEL`,
/// `MODELS`, `WireMsg` were byte-identical copies; `resolve_credential` &
/// `OfflineTransport` are the single source of truth).
pub use nornir_claude::{
    assemble_text, credential_present, resolve_credential, ClaudeError, ClaudeRequest, Credential,
    OfflineTransport, WireMsg, DEFAULT_MODEL, MODELS,
};

/// Who spoke a turn.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
    User,
    Assistant,
}

/// One conversation turn.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Turn {
    pub role: Role,
    pub text: String,
}

// `WireMsg` is now re-exported from the `nornir-claude` leaf (see above) — the
// pane and the leaf share the exact same wire type, so [`EddaBackend`] forwards to
// the leaf's `send_wire` with zero mapping.

/// The pluggable HTTP seam — the HOST wires the real Anthropic client (nornir's
/// `viz::claude::HttpTransport`) or a fake in tests. Kept here so this crate has NO
/// `nornir`/reqwest dependency.
pub trait ClaudeTransport {
    /// Send the conversation for `model` and return the assistant text, or an error
    /// string (e.g. "set ANTHROPIC_API_KEY"). Never panics.
    fn send(&self, model: &str, messages: &[WireMsg]) -> Result<String, String>;
}

/// The seam's task-facing alias. `ClaudeBackend` and [`ClaudeTransport`] are the
/// SAME trait — this re-export lets a host bound on `ClaudeBackend` (the name the
/// nornir/dwarves brief uses) without a second definition. Wire the real Anthropic
/// client, or the built-in [`EchoBackend`] default, behind either name.
pub use ClaudeTransport as ClaudeBackend;

/// The **mock/echo default backend** — the crate's built-in [`ClaudeBackend`] so the
/// pane runs (and every test drives) with **no network and no secrets**. It DELEGATES
/// to the shared edda leaf's [`nornir_claude::OfflineTransport::echoing`] (no twinned
/// echo logic, L5): sending `"2+2?"` under `claude-opus-4-8` yields
/// `"[offline · claude-opus-4-8] 2+2?"`. A host swaps in the real Anthropic transport
/// (via [`EddaBackend`]) at runtime; this keeps the pane self-contained and the
/// demo/tests deterministic. Never panics; never touches the network.
#[derive(Clone, Copy, Debug, Default)]
pub struct EchoBackend;

impl ClaudeTransport for EchoBackend {
    fn send(&self, model: &str, messages: &[WireMsg]) -> Result<String, String> {
        // Forward to the leaf's offline stub — `WireMsg` is the leaf's own type
        // (re-exported), so this is a verbatim hand-off, no per-call mapping.
        use nornir_claude::ClaudeTransport as _;
        nornir_claude::OfflineTransport::echoing().send_wire(model, messages)
    }
}

/// Adapter: mount ANY edda-leaf [`nornir_claude::ClaudeTransport`] — the deterministic
/// [`nornir_claude::OfflineTransport`], or the live `nornir_claude::HttpTransport`
/// (behind the leaf's `http` feature the HOST enables) — as this pane's
/// [`ClaudeTransport`] backend. The leaf's `send_wire` has the byte-identical shape of
/// the pane seam and [`WireMsg`] is the SAME re-exported type, so this forwards with
/// **zero mapping**. The host wires `EddaBackend(HttpTransport)` once instead of
/// re-implementing the Anthropic transport + credential chain (L5).
#[derive(Clone, Copy, Debug, Default)]
pub struct EddaBackend<T>(pub T);

impl<T: nornir_claude::ClaudeTransport> ClaudeTransport for EddaBackend<T> {
    fn send(&self, model: &str, messages: &[WireMsg]) -> Result<String, String> {
        self.0.send_wire(model, messages)
    }
}

/// The complete observable state of the Claude pane.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ClaudeModel {
    pub title: String,
    pub model: String,
    pub turns: Vec<Turn>,
    pub prompt: String,
    pub sending: bool,
    pub error: Option<String>,
}

/// Every input the pane accepts — the only legal way to mutate the model.
/// Externally-tagged snake_case so the `update_json`/robot seam sends
/// `{"prompt_changed":"hi"}`, `{"model_selected":"claude-sonnet-5"}`, the bare
/// `"send"`, and `{"received_ok":"..."}` / `{"received_err":"..."}`.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ClaudeMsg {
    PromptChanged(String),
    ModelSelected(String),
    Send,
    ReceivedOk(String),
    ReceivedErr(String),
}

/// Side work as data: the host fires the Messages API request and posts the reply
/// back as a `Received*` message.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ClaudeEffect {
    Send { model: String, messages: Vec<WireMsg> },
}

/// The Claude conversation pane.
#[derive(Clone, Debug, PartialEq)]
pub struct ClaudePane {
    model: ClaudeModel,
}

impl Default for ClaudePane {
    fn default() -> Self {
        Self::new("Claude")
    }
}

impl ClaudePane {
    pub fn new(title: impl Into<String>) -> Self {
        Self {
            model: ClaudeModel {
                title: title.into(),
                model: DEFAULT_MODEL.to_string(),
                turns: Vec::new(),
                prompt: String::new(),
                sending: false,
                error: None,
            },
        }
    }

    pub fn model(&self) -> &ClaudeModel {
        &self.model
    }

    /// The most recent assistant reply, if any.
    pub fn last_assistant(&self) -> Option<&str> {
        self.model.turns.iter().rev().find(|t| t.role == Role::Assistant).map(|t| t.text.as_str())
    }

    /// Host glue (testable): run a [`ClaudeEffect::Send`] through a [`ClaudeTransport`]
    /// and return the [`ClaudeMsg`] to feed back into [`Elm::update`]. Lives here so the
    /// transport seam is exercised in code without any async/network in the pane.
    pub fn deliver(effect: &ClaudeEffect, transport: &dyn ClaudeTransport) -> ClaudeMsg {
        let ClaudeEffect::Send { model, messages } = effect;
        match transport.send(model, messages) {
            Ok(text) => ClaudeMsg::ReceivedOk(text),
            Err(e) => ClaudeMsg::ReceivedErr(e),
        }
    }
}

impl Elm for ClaudePane {
    type Model = ClaudeModel;
    type Msg = ClaudeMsg;
    type Effect = ClaudeEffect;

    fn title(&self) -> &str {
        &self.model.title
    }

    fn state(&self) -> &Self::Model {
        &self.model
    }

    fn update(&mut self, msg: Self::Msg) -> Vec<Self::Effect> {
        match msg {
            ClaudeMsg::PromptChanged(s) => {
                self.model.prompt = s;
                Vec::new()
            }
            ClaudeMsg::ModelSelected(m) => {
                self.model.model = m;
                Vec::new()
            }
            ClaudeMsg::Send => {
                // No double-send, no empty send (clean no-op).
                if self.model.sending || self.model.prompt.trim().is_empty() {
                    return Vec::new();
                }
                let text = std::mem::take(&mut self.model.prompt);
                self.model.turns.push(Turn { role: Role::User, text });
                self.model.sending = true;
                self.model.error = None;
                let messages = self
                    .model
                    .turns
                    .iter()
                    .map(|t| WireMsg {
                        role: match t.role {
                            Role::User => "user".into(),
                            Role::Assistant => "assistant".into(),
                        },
                        content: t.text.clone(),
                    })
                    .collect();
                vec![ClaudeEffect::Send { model: self.model.model.clone(), messages }]
            }
            ClaudeMsg::ReceivedOk(text) => {
                self.model.sending = false;
                self.model.turns.push(Turn { role: Role::Assistant, text });
                Vec::new()
            }
            ClaudeMsg::ReceivedErr(e) => {
                self.model.sending = false;
                self.model.error = Some(e);
                Vec::new()
            }
        }
    }

    fn view(&self, ui: &mut Ui) -> Vec<Self::Msg> {
        let mut msgs = Vec::new();
        ui.heading("🤖 Claude");
        // PLAIN NUMBERS (no thousands separators) — a nordisk law.
        ui.label(format!("{} · {} turns", self.model.model, self.model.turns.len()));
        ui.separator();
        for turn in &self.model.turns {
            let (who, col) = match turn.role {
                Role::User => ("you", Color32::from_rgb(150, 180, 220)),
                Role::Assistant => ("claude", Color32::from_rgb(120, 200, 120)),
            };
            ui.colored_label(col, format!("{who}: {}", turn.text));
        }
        if let Some(e) = &self.model.error {
            ui.colored_label(Color32::from_rgb(224, 120, 90), e);
        }
        ui.separator();
        ui.horizontal(|ui| {
            let mut prompt = self.model.prompt.clone();
            if ui.text_edit_singleline(&mut prompt).changed() {
                msgs.push(ClaudeMsg::PromptChanged(prompt));
            }
            let can_send = !self.model.sending && !self.model.prompt.trim().is_empty();
            let label = if self.model.sending { "…sending" } else { "Send" };
            if ui.add_enabled(can_send, egui::Button::new(label)).clicked() {
                msgs.push(ClaudeMsg::Send);
            }
        });
        msgs
    }
}

facett_core::impl_facet_via_elm!(ClaudePane, custom_state_json, {
    fn state_json(&self) -> serde_json::Value {
        serde_json::json!({
            "kind": "claude",
            "title": self.model.title,
            "model": self.model.model,
            "turns": self.model.turns.len(),
            "last_assistant": self.last_assistant(),
            "sending": self.model.sending,
            "error": self.model.error,
        })
    }

    fn update_json(&mut self, msg_json: &str) {
        if let Ok(msg) = serde_json::from_str::<ClaudeMsg>(msg_json) {
            let _ = <Self as facett_core::Elm>::update(self, msg);
        }
    }
});

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

    struct FakeOk(&'static str);
    impl ClaudeTransport for FakeOk {
        fn send(&self, _m: &str, _ms: &[WireMsg]) -> Result<String, String> {
            Ok(self.0.to_string())
        }
    }
    struct FakeErr(&'static str);
    impl ClaudeTransport for FakeErr {
        fn send(&self, _m: &str, _ms: &[WireMsg]) -> Result<String, String> {
            Err(self.0.to_string())
        }
    }

    #[test]
    fn default_pane_is_empty() {
        let p = ClaudePane::default();
        assert_eq!(p.model().model, DEFAULT_MODEL);
        assert!(p.model().turns.is_empty());
        assert!(!p.model().sending);
        assert!(p.last_assistant().is_none());
    }

    #[test]
    fn prompt_then_send_emits_send_effect_and_sets_sending() {
        let mut p = ClaudePane::default();
        assert!(p.update(ClaudeMsg::PromptChanged("hello".into())).is_empty());
        let fx = p.update(ClaudeMsg::Send);
        assert_eq!(fx.len(), 1);
        match &fx[0] {
            ClaudeEffect::Send { model, messages } => {
                assert_eq!(model, DEFAULT_MODEL);
                assert_eq!(messages, &[WireMsg { role: "user".into(), content: "hello".into() }]);
            }
        }
        assert!(p.model().sending);
        assert_eq!(p.model().turns.len(), 1); // the user turn
        assert!(p.model().prompt.is_empty()); // prompt consumed
    }

    #[test]
    fn send_empty_or_double_is_a_clean_noop() {
        let mut p = ClaudePane::default();
        assert!(p.update(ClaudeMsg::Send).is_empty()); // empty prompt
        p.update(ClaudeMsg::PromptChanged("hi".into()));
        assert_eq!(p.update(ClaudeMsg::Send).len(), 1);
        assert!(p.update(ClaudeMsg::Send).is_empty()); // already sending
    }

    #[test]
    fn received_ok_adds_assistant_and_clears_sending() {
        let mut p = ClaudePane::default();
        p.update(ClaudeMsg::PromptChanged("q".into()));
        p.update(ClaudeMsg::Send);
        p.update(ClaudeMsg::ReceivedOk("the answer".into()));
        assert!(!p.model().sending);
        assert_eq!(p.model().turns.len(), 2);
        assert_eq!(p.last_assistant(), Some("the answer"));
    }

    /// The **transport seam driven end-to-end IN CODE**: Elm `update` emits the Send
    /// effect, `deliver` runs it through a fake transport, the reply feeds back — no
    /// network, no AI in the loop.
    #[test]
    fn transport_seam_round_trip_with_fake() {
        let mut p = ClaudePane::default();
        p.update(ClaudeMsg::PromptChanged("ping".into()));
        let fx = p.update(ClaudeMsg::Send);
        let back = ClaudePane::deliver(&fx[0], &FakeOk("pong"));
        p.update(back);
        assert_eq!(p.last_assistant(), Some("pong"));
        assert!(!p.model().sending);
    }

    /// The **public mock/echo default** drives end-to-end with NO network, NO
    /// secrets — the built-in [`EchoBackend`] a host uses before wiring the real
    /// Anthropic client. Proves the crate is self-contained (task: "builds + tests
    /// with no network and no secrets").
    #[test]
    fn echo_backend_default_round_trip_no_secrets() {
        let mut p = ClaudePane::default();
        p.update(ClaudeMsg::PromptChanged("2+2?".into()));
        let fx = p.update(ClaudeMsg::Send);
        // Drive through the seam under its task-facing alias name too.
        let backend: &dyn ClaudeBackend = &EchoBackend;
        let back = ClaudePane::deliver(&fx[0], backend);
        p.update(back);
        // `EchoBackend` now delegates to the shared leaf's `OfflineTransport::echoing`,
        // so the reply carries the leaf's `[offline · …]` tag (not a twinned `[echo · …]`).
        assert_eq!(p.last_assistant(), Some("[offline · claude-opus-4-8] 2+2?"));
        assert!(!p.model().sending);
        assert!(p.model().error.is_none());
    }

    /// **L5 dedup proof (compile-time):** `facett_claude::WireMsg` IS
    /// `nornir_claude::WireMsg` — a re-export, not a twin. This only compiles if the
    /// two names resolve to the SAME type (assigning one to the other with no `From`).
    #[test]
    fn wire_msg_is_the_shared_edda_leaf_type() {
        let from_leaf: WireMsg = nornir_claude::WireMsg::user("hi");
        let from_here: nornir_claude::WireMsg =
            WireMsg { role: "user".into(), content: "hi".into() };
        assert_eq!(from_leaf, from_here);
        // the re-exported consts are the leaf's, too.
        assert_eq!(DEFAULT_MODEL, nornir_claude::DEFAULT_MODEL);
        assert_eq!(MODELS, nornir_claude::MODELS);
    }

    /// **L5 routing proof:** the pane's Send effect is delivered through the SHARED
    /// edda leaf `nornir_claude::OfflineTransport` (wrapped in [`EddaBackend`]), not a
    /// facett-local transport. RED-when-broken: the leaf's verbatim `[offline · …]`
    /// echo must come back through `EddaBackend → send_wire`.
    #[test]
    fn pane_routes_through_the_edda_leaf_offline_transport() {
        let mut p = ClaudePane::default();
        p.update(ClaudeMsg::PromptChanged("via leaf".into()));
        let fx = p.update(ClaudeMsg::Send);
        let backend = EddaBackend(nornir_claude::OfflineTransport::echoing());
        let back = ClaudePane::deliver(&fx[0], &backend);
        p.update(back);
        assert_eq!(p.last_assistant(), Some("[offline · claude-opus-4-8] via leaf"));
        assert!(!p.model().sending);
    }

    #[test]
    fn offline_error_is_a_clean_state_not_a_panic() {
        let mut p = ClaudePane::default();
        p.update(ClaudeMsg::PromptChanged("x".into()));
        let fx = p.update(ClaudeMsg::Send);
        let back = ClaudePane::deliver(&fx[0], &FakeErr("set ANTHROPIC_API_KEY"));
        p.update(back);
        assert!(!p.model().sending);
        assert_eq!(p.model().error.as_deref(), Some("set ANTHROPIC_API_KEY"));
        assert!(p.last_assistant().is_none());
    }

    /// The **robot/remote-control seam driven IN CODE**: the whole conversation
    /// through the same `update_json` the headless robot harness uses (the host
    /// posts the transport reply back as a `received_ok` msg), then assert
    /// `state_json`. No pixels, no AI — self-driving.
    #[test]
    fn robot_json_drive_prompt_send_receive() {
        let mut p = ClaudePane::default();
        Panel::update_json(&mut p, r#"{"prompt_changed":"hello claude"}"#);
        Panel::update_json(&mut p, "\"send\"");
        assert_eq!(Panel::state_json(&p)["sending"], true);
        Panel::update_json(&mut p, r#"{"received_ok":"hi, human"}"#);
        let st = Panel::state_json(&p);
        assert_eq!(st["kind"], "claude");
        assert_eq!(st["turns"], 2);
        assert_eq!(st["last_assistant"], "hi, human");
        assert_eq!(st["sending"], false);
        // malformed / unknown messages are ignored, not a panic.
        Panel::update_json(&mut p, "not json [[[");
        Panel::update_json(&mut p, r#"{"nonsense":true}"#);
        assert_eq!(Panel::state_json(&p)["last_assistant"], "hi, human");
    }

    /// Emit the test-matrix cell so `--features testmatrix` SEES this pane's
    /// remote-drive is covered (green ⟺ nothing untested). No-op otherwise.
    #[test]
    fn testmatrix_cell_claude_remote_drive() {
        let mut p = ClaudePane::default();
        Panel::update_json(&mut p, r#"{"prompt_changed":"2+2?"}"#);
        Panel::update_json(&mut p, "\"send\"");
        Panel::update_json(&mut p, r#"{"received_ok":"4"}"#);
        let ok = Panel::state_json(&p)["last_assistant"] == "4";
        facett_core::testmatrix::emit(
            "facett-claude/prompt",
            "robot_json_drive_prompt_send_receive",
            ok,
            "prompt→send→received via update_json yields the assistant turn in state_json",
        );
        assert!(ok);
    }
}