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);
}
}