nornir 0.5.3

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
//! πŸ€– **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::{Theme, RED};

/// 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_api_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,
                        "ANTHROPIC_API_KEY is not set β€” 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-…` and restart β€” the 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 ANTHROPIC_API_KEY from the environment");
        }
        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);
        }
    }
}