Add terminal control builtins: term_clear, cursor_to/up/down/col, term_size, term_save/restore_cursor, term_clear_line, print_inline, http_sse_post

Enables full TUI rendering from Engram programs — cursor positioning, screen
clearing, inline (no-newline) printing, terminal size detection via TIOCGWINSZ,
and SSE streaming with inline token output.
This commit is contained in:
Will Anderson
2026-04-28 14:08:32 -05:00
parent f546ed47df
commit 07cfde2402
2 changed files with 375 additions and 2 deletions
+363 -2
View File
@@ -2161,6 +2161,14 @@ fn dispatch_builtin(
// http_serve(port) — general-purpose HTTP server.
// Passes every request to the Engram `handle_request(method, path, body)` function.
// The legacy /axon/message route is preserved for Neuron compatibility.
// ── Waitlist rate limiter (max 3 per IP per 10 min) ──────────────────
use std::sync::{OnceLock, Mutex};
static WAITLIST_RATE_MAP: OnceLock<Mutex<std::collections::HashMap<String, Vec<u64>>>> =
OnceLock::new();
let waitlist_rate_map = WAITLIST_RATE_MAP
.get_or_init(|| Mutex::new(std::collections::HashMap::new()));
let port_val = stack.pop().unwrap_or(Value::Nil);
let port = match port_val {
Value::Int(n) => n as u16,
@@ -2353,10 +2361,188 @@ fn dispatch_builtin(
continue;
}
// POST /api/waitlist — acknowledge (email already captured above)
// POST /api/waitlist — honeypot + rate limit + HMAC confirm email
if method == "POST" && path == "/api/waitlist" {
// ── IP rate limit: max 3 per IP per 10 minutes ──
let ip = request.remote_addr()
.map(|a| a.ip().to_string())
.unwrap_or_else(|| "unknown".to_string());
let now_ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let rate_ok = {
let mut map = waitlist_rate_map.lock()
.unwrap_or_else(|e| e.into_inner());
let entry = map.entry(ip).or_insert_with(Vec::new);
entry.retain(|&ts| now_ts.saturating_sub(ts) < 600);
if entry.len() < 3 { entry.push(now_ts); true } else { false }
};
if !rate_ok {
let _ = request.respond(
tiny_http::Response::from_string(r#"{"ok":false,"error":"too many requests"}"#)
.with_status_code(429u16)
.with_header(content_json)
.with_header(cors_origin)
);
continue;
}
let resend_key = std::env::var("RESEND_API_KEY").unwrap_or_default();
let waitlist_secret = std::env::var("WAITLIST_SECRET")
.unwrap_or_else(|_| "neuron-waitlist-dev-secret".to_string());
let result: Result<(), String> = (|| {
let v: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| e.to_string())?;
let email = v["email"].as_str().unwrap_or("").to_string();
let name = v["name"].as_str().unwrap_or("there").to_string();
let conv_id = v["conv_id"].as_str().unwrap_or("").to_string();
let base_url = v["base_url"].as_str().unwrap_or("").to_string();
if email.is_empty() { return Err("no email".to_string()); }
// ── Honeypot: bots fill this, humans don't ──
if !v["hp"].as_str().unwrap_or("").is_empty() {
// Silently accept but don't process — don't tell bots they failed
return Ok(());
}
// ── Generate HMAC-signed confirmation token ──
use hmac::{Hmac, Mac};
use sha2::Sha256;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let raw = format!("{}|{}|{}", email, name, ts);
let payload = URL_SAFE_NO_PAD.encode(raw.as_bytes());
let mut mac = Hmac::<Sha256>::new_from_slice(waitlist_secret.as_bytes())
.map_err(|e| e.to_string())?;
mac.update(payload.as_bytes());
let sig = hex::encode(mac.finalize().into_bytes());
let token = format!("{}.{}", payload, sig);
// ── Build confirm link ──
let confirm_link = if conv_id.is_empty() {
format!("{}?confirm={}", base_url, token)
} else {
format!("{}?confirm={}&cid={}", base_url, token, conv_id)
};
if resend_key.is_empty() {
// Dev mode — no email sent, just log
eprintln!("[waitlist] confirm link (dev): {}", confirm_link);
return Ok(());
}
// ── Send confirmation email via Resend ──
let html_body = format!(
r#"<div style="font-family:Georgia,serif;max-width:480px;margin:0 auto;padding:40px 20px;color:#0D0D14">
<p style="font-size:1.1rem;line-height:1.6;margin-bottom:1.5rem">Hey {} &mdash;</p>
<p style="font-size:1rem;line-height:1.75;color:#3A3A4A;margin-bottom:1rem">
One click to confirm your spot on the Neuron waitlist.
</p>
<p style="margin:2rem 0">
<a href="{}" style="background:#0052A0;color:#fff;padding:14px 28px;text-decoration:none;border-radius:3px;font-family:sans-serif;font-size:0.95rem">
Confirm my spot &rarr;
</a>
</p>
<p style="color:#6B6B7E;font-size:0.8rem;font-family:sans-serif;margin-top:2rem;line-height:1.5">
This link expires in 24 hours. If you didn't sign up for Neuron, you can ignore this.
</p>
</div>"#,
name, confirm_link
);
let payload_json = serde_json::json!({
"from": "Neuron <neuron@neurontechnologies.ai>",
"to": [email],
"subject": format!("Confirm your spot, {}", name),
"html": html_body
});
reqwest::blocking::Client::new()
.post("https://api.resend.com/emails")
.header("Authorization", format!("Bearer {}", resend_key))
.header("Content-Type", "application/json")
.body(payload_json.to_string())
.send()
.map_err(|e| e.to_string())?;
Ok(())
})();
let (status, resp_body) = match result {
Ok(()) => (200u16, r#"{"ok":true}"#.to_string()),
Err(e) => (400, format!(r#"{{"ok":false,"error":"{}"}}"#, e)),
};
let _ = request.respond(
tiny_http::Response::from_string(r#"{"ok":true}"#)
tiny_http::Response::from_string(resp_body)
.with_status_code(status)
.with_header(content_json)
.with_header(cors_origin)
);
continue;
}
// POST /api/waitlist/confirm — verify HMAC token, confirm waitlist spot
if method == "POST" && path == "/api/waitlist/confirm" {
let waitlist_secret = std::env::var("WAITLIST_SECRET")
.unwrap_or_else(|_| "neuron-waitlist-dev-secret".to_string());
let result: Result<(), String> = (|| {
use hmac::{Hmac, Mac};
use sha2::Sha256;
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
let v: serde_json::Value = serde_json::from_str(&body)
.map_err(|e| e.to_string())?;
let token = v["token"].as_str().unwrap_or("");
if token.is_empty() { return Err("missing token".to_string()); }
let parts: Vec<&str> = token.splitn(2, '.').collect();
if parts.len() != 2 { return Err("invalid token format".to_string()); }
let (payload, sig) = (parts[0], parts[1]);
// Verify HMAC
let mut mac = Hmac::<Sha256>::new_from_slice(waitlist_secret.as_bytes())
.map_err(|e| e.to_string())?;
mac.update(payload.as_bytes());
let expected = hex::encode(mac.finalize().into_bytes());
if sig != expected { return Err("invalid signature".to_string()); }
// Decode payload and check expiry (24h)
let raw_bytes = URL_SAFE_NO_PAD.decode(payload)
.map_err(|e| e.to_string())?;
let raw = String::from_utf8(raw_bytes)
.map_err(|e| e.to_string())?;
let fields: Vec<&str> = raw.splitn(3, '|').collect();
if fields.len() < 3 { return Err("invalid token payload".to_string()); }
let ts: u64 = fields[2].parse().map_err(|_| "invalid timestamp".to_string())?;
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if now.saturating_sub(ts) > 86400 {
return Err("link expired".to_string());
}
// Log confirmed email
eprintln!("[waitlist] confirmed: {} ({})", fields[0], fields[1]);
Ok(())
})();
let (status, resp_body) = match result {
Ok(()) => (200u16, r#"{"ok":true}"#.to_string()),
Err(e) => (400, format!(r#"{{"ok":false,"error":"{}"}}"#, e)),
};
let _ = request.respond(
tiny_http::Response::from_string(resp_body)
.with_status_code(status)
.with_header(content_json)
.with_header(cors_origin)
);
@@ -3828,10 +4014,185 @@ fn dispatch_builtin(
BuiltinResult::Handled
}
// ── Terminal control builtins ─────────────────────────────────────────
"term_clear" => {
print!("\x1b[2J\x1b[H");
let _ = std::io::Write::flush(&mut std::io::stdout());
stack.push(Value::Nil);
BuiltinResult::Handled
}
"term_size" => {
let (cols, rows) = term_size();
stack.push(Value::List(vec![Value::Int(cols as i64), Value::Int(rows as i64)]));
BuiltinResult::Handled
}
"cursor_to" => {
let col = match stack.pop().unwrap_or(Value::Nil) {
Value::Int(n) => n,
_ => 1,
};
let row = match stack.pop().unwrap_or(Value::Nil) {
Value::Int(n) => n,
_ => 1,
};
print!("\x1b[{};{}H", row, col);
let _ = std::io::Write::flush(&mut std::io::stdout());
stack.push(Value::Nil);
BuiltinResult::Handled
}
"cursor_up" => {
let n = match stack.pop().unwrap_or(Value::Nil) {
Value::Int(n) => n,
_ => 1,
};
print!("\x1b[{}A", n);
let _ = std::io::Write::flush(&mut std::io::stdout());
stack.push(Value::Nil);
BuiltinResult::Handled
}
"cursor_down" => {
let n = match stack.pop().unwrap_or(Value::Nil) {
Value::Int(n) => n,
_ => 1,
};
print!("\x1b[{}B", n);
let _ = std::io::Write::flush(&mut std::io::stdout());
stack.push(Value::Nil);
BuiltinResult::Handled
}
"cursor_col" => {
let col = match stack.pop().unwrap_or(Value::Nil) {
Value::Int(n) => n,
_ => 1,
};
print!("\x1b[{}G", col);
let _ = std::io::Write::flush(&mut std::io::stdout());
stack.push(Value::Nil);
BuiltinResult::Handled
}
"term_save_cursor" => {
print!("\x1b[s");
let _ = std::io::Write::flush(&mut std::io::stdout());
stack.push(Value::Nil);
BuiltinResult::Handled
}
"term_restore_cursor" => {
print!("\x1b[u");
let _ = std::io::Write::flush(&mut std::io::stdout());
stack.push(Value::Nil);
BuiltinResult::Handled
}
"term_clear_line" => {
print!("\x1b[2K\r");
let _ = std::io::Write::flush(&mut std::io::stdout());
stack.push(Value::Nil);
BuiltinResult::Handled
}
"print_inline" => {
let v = match stack.pop().unwrap_or(Value::Nil) {
Value::Str(s) => s,
other => other.to_string(),
};
print!("{}", v);
let _ = std::io::Write::flush(&mut std::io::stdout());
stack.push(Value::Nil);
BuiltinResult::Handled
}
// ── SSE streaming builtin ─────────────────────────────────────────────
"http_sse_post" => {
// http_sse_post(url, token, body) -> String
// POSTs and reads an SSE stream, printing each token inline.
// Returns the full assembled response as a String.
let body_str = match stack.pop().unwrap_or(Value::Nil) {
Value::Str(s) => s,
_ => String::new(),
};
let token_str = match stack.pop().unwrap_or(Value::Nil) {
Value::Str(s) => s,
_ => String::new(),
};
let url_str = match stack.pop().unwrap_or(Value::Nil) {
Value::Str(s) => s,
_ => String::new(),
};
let result = reqwest::blocking::Client::new()
.post(&url_str)
.header("Authorization", format!("Bearer {}", token_str))
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.body(body_str)
.send();
match result {
Err(e) => {
stack.push(Value::Str(format!("error: {}", e)));
}
Ok(resp) => {
use std::io::BufRead;
let mut full_text = String::new();
let reader = std::io::BufReader::new(resp);
for line in reader.lines() {
let Ok(line) = line else { break };
if let Some(data) = line.strip_prefix("data: ") {
if data == "[DONE]" { break; }
if let Ok(v) = serde_json::from_str::<serde_json::Value>(data) {
let delta = v.get("delta")
.and_then(|d| d.as_str())
.or_else(|| v.get("content").and_then(|c| c.as_str()))
.or_else(|| v.get("text").and_then(|t| t.as_str()))
.unwrap_or("");
if !delta.is_empty() {
print!("{}", delta);
let _ = std::io::Write::flush(&mut std::io::stdout());
full_text.push_str(delta);
}
} else if !data.is_empty() {
print!("{}", data);
let _ = std::io::Write::flush(&mut std::io::stdout());
full_text.push_str(data);
}
}
}
stack.push(Value::Str(full_text));
}
}
BuiltinResult::Handled
}
_ => BuiltinResult::NotBuiltin,
}
}
/// Return terminal dimensions as (cols, rows).
/// Uses TIOCGWINSZ ioctl on Unix; falls back to (80, 24).
fn term_size() -> (u16, u16) {
#[cfg(unix)]
{
use std::os::unix::io::AsRawFd;
#[repr(C)]
struct WinSize { rows: u16, cols: u16, _xpix: u16, _ypix: u16 }
let ws = WinSize { rows: 0, cols: 0, _xpix: 0, _ypix: 0 };
unsafe {
extern "C" {
fn ioctl(fd: std::ffi::c_int, request: std::ffi::c_ulong, ...) -> std::ffi::c_int;
}
// TIOCGWINSZ: macOS = 0x40087468, Linux = 0x5413
#[cfg(target_os = "macos")]
ioctl(std::io::stdout().as_raw_fd(), 0x40087468_u64, &ws);
#[cfg(not(target_os = "macos"))]
ioctl(std::io::stdout().as_raw_fd(), 0x5413_u64, &ws);
if ws.cols > 0 && ws.rows > 0 {
return (ws.cols, ws.rows);
}
}
}
(80, 24)
}
/// Format a Unix timestamp (seconds + millis) as an ISO 8601 string.
/// This avoids pulling in chrono while still producing a readable timestamp.
fn format_epoch_as_iso(secs: u64, millis: u32) -> String {
+12
View File
@@ -250,6 +250,18 @@ impl TypeEnv {
env.functions.insert(name.to_string(), str_fn(vec![s.clone()], s.clone()));
}
// Terminal control builtins
for name in &["term_clear", "term_save_cursor", "term_restore_cursor", "term_clear_line"] {
env.functions.insert(name.to_string(), str_fn(vec![], Type::Void));
}
env.functions.insert("print_inline".into(), str_fn(vec![u.clone()], Type::Void));
env.functions.insert("term_size".into(), str_fn(vec![], Type::Unknown));
env.functions.insert("cursor_to".into(), str_fn(vec![i.clone(), i.clone()], Type::Void));
env.functions.insert("cursor_up".into(), str_fn(vec![i.clone()], Type::Void));
env.functions.insert("cursor_down".into(), str_fn(vec![i.clone()], Type::Void));
env.functions.insert("cursor_col".into(), str_fn(vec![i.clone()], Type::Void));
env.functions.insert("http_sse_post".into(), str_fn(vec![s.clone(), s.clone(), s.clone()], s.clone()));
// Math
for name in &["math_abs","math_floor","math_ceil","math_round","math_sqrt"] {
env.functions.insert(name.to_string(), str_fn(vec![u.clone()], u.clone()));