src/viz/claude_tab.rs
//! 🤖 **Claude tab** — a facett-style Elm conversation pane over the Anthropic
//! Messages API.
//!
//! Model / Msg / update / view: [`ClaudeModel`] is the pure observable state (the
//! user↔assistant turns, the prompt input, the selected model, the `sending`
//! flag); [`ClaudeMsg`] is every input; [`ClaudeModel::update`] is the only state
//! transition and returns [`ClaudeEffect`]s the live [`ClaudeTab`] runs (spawn the
//! send off the paint thread, poll it back). The HTTP call is behind the
//! [`ClaudeTransport`](super::claude::ClaudeTransport) seam so the robot test
//! drives a real send through a fake — no network in CI.
//!
//! Everything the tab renders is in [`ClaudeTab::state_json`] (LAW #6): turn count
//! + last-assistant text + the `sending` flag, so a headless robot asserts a send
//! landed without a pixel.
use std::sync::{Arc, Mutex};
use eframe::egui;
use super::claude::{
self, ClaudeError, ClaudeMessage, ClaudeRequest, ClaudeTransport, DEFAULT_MODEL, MODELS,
};
use super::facett_theme::{RED, Theme};
/// Who authored a turn.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Role {
User,
Assistant,
}
impl Role {
fn as_str(self) -> &'static str {
match self {
Role::User => "user",
Role::Assistant => "assistant",
}
}
}
/// One rendered conversation turn.
#[derive(Clone, Debug)]
pub struct Turn {
pub role: Role,
pub text: String,
}
// ── Elm core ────────────────────────────────────────────────────────────────
/// The pure, observable model — no egui, no transport.
#[derive(Clone, Debug)]
pub struct ClaudeModel {
pub turns: Vec<Turn>,
pub prompt: String,
pub model: String,
pub sending: bool,
pub last_error: Option<String>,
}
impl Default for ClaudeModel {
fn default() -> Self {
Self {
turns: Vec::new(),
prompt: String::new(),
model: DEFAULT_MODEL.to_string(),
sending: false,
last_error: None,
}
}
}
/// Every input into the Elm core.
#[derive(Clone, Debug)]
pub enum ClaudeMsg {
/// The prompt textbox changed.
PromptChanged(String),
/// The model selector changed.
ModelSelected(String),
/// The user pressed Send.
Send,
/// The worker returned the assistant text (or an error string).
Received(Result<String, String>),
}
/// A side-effect the live tab must run after an [`ClaudeModel::update`].
#[derive(Clone, Debug)]
pub enum ClaudeEffect {
/// Fire a Messages API request for the given model over `messages`.
Send {
model: String,
messages: Vec<ClaudeMessage>,
},
}
impl ClaudeModel {
/// The only state transition. Returns the effects the host runs.
pub fn update(&mut self, msg: ClaudeMsg) -> Vec<ClaudeEffect> {
match msg {
ClaudeMsg::PromptChanged(s) => {
self.prompt = s;
Vec::new()
}
ClaudeMsg::ModelSelected(m) => {
self.model = m;
Vec::new()
}
ClaudeMsg::Send => {
if self.sending || self.prompt.trim().is_empty() {
return Vec::new();
}
let text = std::mem::take(&mut self.prompt);
self.turns.push(Turn {
role: Role::User,
text,
});
self.sending = true;
self.last_error = None;
let messages = self
.turns
.iter()
.map(|t| ClaudeMessage {
role: t.role.as_str().into(),
content: t.text.clone(),
})
.collect();
vec![ClaudeEffect::Send {
model: self.model.clone(),
messages,
}]
}
ClaudeMsg::Received(Ok(text)) => {
self.sending = false;
self.turns.push(Turn {
role: Role::Assistant,
text,
});
Vec::new()
}
ClaudeMsg::Received(Err(e)) => {
self.sending = false;
self.last_error = Some(e);
Vec::new()
}
}
}
/// The most-recent assistant turn's text, if any.
pub fn last_assistant(&self) -> Option<&str> {
self.turns
.iter()
.rev()
.find(|t| t.role == Role::Assistant)
.map(|t| t.text.as_str())
}
}
// ── Live tab (host) ─────────────────────────────────────────────────────────
/// The result slot a worker thread posts a finished send into.
type Slot = Arc<Mutex<Option<Result<String, String>>>>;
/// 🤖 The Claude tab: the Elm [`ClaudeModel`] + a pluggable
/// [`ClaudeTransport`] + the one in-flight send slot.
pub struct ClaudeTab {
model: ClaudeModel,
transport: Arc<dyn ClaudeTransport>,
inflight: Option<Slot>,
theme: Theme,
/// Token-login (N8): a per-session API token entered in the UI when
/// `ANTHROPIC_API_KEY` is not exported. Held for the process lifetime (never
/// persisted to disk) and threaded into each send as the `x-api-key` header.
session_key: String,
/// The unsubmitted contents of the token input box (masked in the UI).
key_input: String,
}
impl Default for ClaudeTab {
fn default() -> Self {
Self {
model: ClaudeModel::default(),
transport: claude::default_transport(),
inflight: None,
theme: Theme::default(),
session_key: String::new(),
key_input: String::new(),
}
}
}
impl ClaudeTab {
pub fn new() -> Self {
Self::default()
}
/// Set the facett palette the pane paints with.
pub fn set_palette(&mut self, t: Theme) {
self.theme = t;
}
/// Inject a transport — the test seam (drive a real send through a
/// [`FakeTransport`](super::claude::FakeTransport), no network).
pub fn set_transport_for_test(&mut self, transport: Arc<dyn ClaudeTransport>) {
self.transport = transport;
}
/// Whether an API token is available to send with — either the exported
/// `ANTHROPIC_API_KEY` or a per-session token entered via the token-login
/// field (N8).
pub fn key_available(&self) -> bool {
claude::api_key_present() || !self.session_key.trim().is_empty()
}
/// Adopt a per-session API token (the token-login flow / test seam). Blank
/// input clears it. Never written to disk.
pub fn set_session_key(&mut self, key: impl Into<String>) {
self.session_key = key.into().trim().to_string();
}
/// Drive one [`ClaudeMsg`] through the Elm core and run its effects.
fn dispatch(&mut self, msg: ClaudeMsg) {
for effect in self.model.update(msg) {
match effect {
ClaudeEffect::Send { model, messages } => self.spawn_send(model, messages),
}
}
}
/// Send `messages` off the paint thread; the worker posts the result to a
/// slot the live [`Self::draw`]/[`Self::poll`] drains (mirrors the Build tab).
fn spawn_send(&mut self, model: String, messages: Vec<ClaudeMessage>) {
let slot: Slot = Arc::new(Mutex::new(None));
let worker_slot = Arc::clone(&slot);
let transport = Arc::clone(&self.transport);
// Thread the per-session token (if any) into the request so the real
// transport can use it as `x-api-key` when the env var is unset (N8).
let session_key = (!self.session_key.trim().is_empty()).then(|| self.session_key.clone());
std::thread::spawn(move || {
let req = ClaudeRequest::new(model, messages).with_session_key(session_key);
let result = transport.send(&req).map_err(|e: ClaudeError| e.to_string());
*worker_slot.lock().unwrap() = Some(result);
});
self.inflight = Some(slot);
}
/// Drain a finished send, feeding it back through the Elm core. Called each
/// frame (and by the test after pumping frames).
pub fn poll(&mut self) {
let ready = self
.inflight
.as_ref()
.and_then(|s| s.lock().unwrap().take());
if let Some(result) = ready {
self.inflight = None;
let ok = result.is_ok();
nornir_testmatrix::functional_status(
"nornir/viz/claude",
"messages_send",
ok,
&match &result {
Ok(t) => format!("assistant reply, {} char(s)", t.len()),
Err(e) => e.clone(),
},
);
self.dispatch(ClaudeMsg::Received(result));
}
}
/// Send the current prompt (the test drives this directly, then pumps frames
/// until [`Self::poll`] feeds the assistant turn back).
pub fn send_for_test(&mut self) {
self.dispatch(ClaudeMsg::Send);
}
/// Set the prompt text (test seam).
pub fn set_prompt_for_test(&mut self, text: impl Into<String>) {
self.dispatch(ClaudeMsg::PromptChanged(text.into()));
}
/// 🤖 Claude tab's `state_json` slice (LAW #6): the conversation as data, the
/// `sending` flag, the selected model, whether the API key is present, and the
/// last error — enough for a robot to assert a send landed without a pixel.
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"turn_count": self.model.turns.len(),
"sending": self.model.sending,
"model": self.model.model,
"models": MODELS,
"api_key_present": claude::api_key_present(),
// N8: a per-session token was entered in the UI (value never exposed).
"session_key_present": !self.session_key.trim().is_empty(),
// Effective availability: env key OR a session token — the gate the
// Send button + robot assert on.
"key_available": self.key_available(),
"prompt_len": self.model.prompt.chars().count(),
"last_assistant": self.model.last_assistant(),
"last_error": self.model.last_error,
"turns": self.model.turns.iter().map(|t| serde_json::json!({
"role": t.role.as_str(),
"text": t.text,
})).collect::<Vec<_>>(),
"palette": self.theme.name,
})
}
pub fn draw(&mut self, ui: &mut egui::Ui) {
// Drain any finished send first so the frame reflects it.
self.poll();
// Keep repainting while a send is in flight so the reply lands promptly.
if self.inflight.is_some() {
ui.ctx().request_repaint();
}
let theme = self.theme;
ui.horizontal(|ui| {
ui.heading("🤖 Claude");
ui.separator();
let mut selected = self.model.model.clone();
egui::ComboBox::from_id_salt("claude_model_selector")
.selected_text(&selected)
.show_ui(ui, |ui| {
for m in MODELS {
ui.selectable_value(&mut selected, (*m).to_string(), *m);
}
});
if selected != self.model.model {
self.dispatch(ClaudeMsg::ModelSelected(selected));
}
});
// ── Token login (N8) ────────────────────────────────────────────────────
// If `ANTHROPIC_API_KEY` isn't exported, offer a token field the user can
// paste an `sk-ant-…` key into; it's held for THIS session only (never
// written to disk) and used as `x-api-key` for every send. Once a session
// token is set the tab reports ● ready and hides the field behind a clear
// button.
if !claude::api_key_present() {
if self.session_key.trim().is_empty() {
ui.horizontal_wrapped(|ui| {
ui.colored_label(
theme.text_dim,
"No key in ANTHROPIC_API_KEY or ~/.nornir/anthropic-key — \
paste a token to use for this session:",
);
});
ui.horizontal(|ui| {
let resp = ui.add(
egui::TextEdit::singleline(&mut self.key_input)
.password(true)
.hint_text("sk-ant-…")
.desired_width(320.0),
);
let enter = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if (ui.button("Use token").clicked() || enter)
&& !self.key_input.trim().is_empty()
{
self.session_key = self.key_input.trim().to_string();
self.key_input.clear();
}
});
ui.label(
egui::RichText::new(
"or `export ANTHROPIC_API_KEY=sk-ant-…` / write the key to \
~/.nornir/anthropic-key (chmod 600) and restart — the pasted token \
stays in memory for this session only.",
)
.small()
.color(theme.text_dim),
);
} else {
ui.horizontal(|ui| {
ui.colored_label(theme.accent, "● session token set");
if ui.button("Clear token").clicked() {
self.session_key.clear();
}
});
}
} else {
ui.colored_label(
theme.text_dim,
"● using the Anthropic key from ANTHROPIC_API_KEY or ~/.nornir/anthropic-key",
);
}
ui.separator();
// ── Conversation ──────────────────────────────────────────────────────
egui::ScrollArea::vertical()
.max_height(ui.available_height() - 90.0)
.auto_shrink([false, false])
.stick_to_bottom(true)
.show(ui, |ui| {
for turn in &self.model.turns {
let (who, col) = match turn.role {
Role::User => ("you", theme.accent),
Role::Assistant => ("claude", theme.text),
};
ui.horizontal_wrapped(|ui| {
ui.colored_label(col, format!("{who}:"));
ui.label(&turn.text);
});
ui.add_space(4.0);
}
if self.model.sending {
ui.horizontal(|ui| {
ui.spinner();
ui.colored_label(theme.text_dim, "claude is thinking…");
});
}
if let Some(err) = &self.model.last_error {
ui.colored_label(RED, err);
}
});
ui.separator();
// ── Prompt input + Send ─────────────────────────────────────────────────
let mut prompt = self.model.prompt.clone();
let mut send = false;
ui.horizontal(|ui| {
let resp = ui.add_enabled(
!self.model.sending,
egui::TextEdit::singleline(&mut prompt)
.hint_text("prompt:")
.desired_width(ui.available_width() - 90.0),
);
let enter = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
if ui
.add_enabled(!self.model.sending, egui::Button::new("Send"))
.clicked()
|| enter
{
send = true;
}
});
if prompt != self.model.prompt {
self.dispatch(ClaudeMsg::PromptChanged(prompt));
}
if send {
self.dispatch(ClaudeMsg::Send);
}
}
}