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

nordisk/nornir

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