Commit 86dd763
Changed files (3)
lib
elelem
spec
elelem
lib/elelem/server.rb
@@ -0,0 +1,217 @@
+# frozen_string_literal: true
+
+module Elelem
+ class Server
+ def initialize(agent, port: 4567, address: "0.0.0.0")
+ @agent = agent
+ @port = port
+ @address = address
+ @terminal = agent.terminal
+ end
+
+ def start
+ server = WEBrick::HTTPServer.new(
+ Port: @port,
+ BindAddress: @address,
+ Logger: WEBrick::Log.new(File::NULL),
+ AccessLog: []
+ )
+ server.mount_proc("/", &method(:index))
+ server.mount_proc("/events", &method(:events))
+ server.mount_proc("/message", &method(:message))
+ server.mount_proc("/answer", &method(:answer))
+
+ trap("INT") { server.shutdown }
+ $stdout.puts "elelem v#{VERSION} listening on http://#{@address}:#{@port}"
+ server.start
+ end
+
+ private
+
+ def index(_req, res)
+ res.content_type = "text/html; charset=utf-8"
+ res.body = INDEX
+ end
+
+ def events(_req, res)
+ res.content_type = "text/event-stream"
+ res["Cache-Control"] = "no-cache"
+ res.chunked = true
+ res.body = ->(out) { stream(out) }
+ end
+
+ def stream(out)
+ events = @terminal.subscribe
+ loop do
+ out.write("data: #{JSON.dump(events.pop)}\n\n")
+ end
+ rescue IOError, Errno::EPIPE, Errno::ECONNRESET
+ nil
+ ensure
+ @terminal.unsubscribe(events)
+ end
+
+ def message(req, res)
+ text = JSON.parse(req.body)["text"].to_s
+ Thread.new do
+ @agent.turn(text)
+ rescue => e
+ @terminal.say("Error: #{e.message}")
+ ensure
+ @terminal.done
+ end
+ res.status = 204
+ end
+
+ def answer(req, res)
+ @terminal.answer(JSON.parse(req.body)["text"].to_s)
+ res.status = 204
+ end
+
+ INDEX = <<~HTML
+ <!doctype html>
+ <html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
+ <title>elelem</title>
+ <style>
+ :root { color-scheme: dark; }
+ * { box-sizing: border-box; }
+ body {
+ margin: 0; height: 100dvh; display: flex; flex-direction: column;
+ background: #16161a; color: #e6e6e6;
+ font: 16px/1.55 ui-sans-serif, system-ui, -apple-system, sans-serif;
+ }
+ #log { flex: 1; overflow-y: auto; padding: 1rem; -webkit-overflow-scrolling: touch; }
+ .msg { max-width: 46rem; margin: 0 auto 1rem; padding: .65rem .85rem; border-radius: .6rem; }
+ .user { background: #2d3a4f; }
+ .assistant { background: #22222a; }
+ .thinking { opacity: .55; font-style: italic; white-space: pre-wrap; }
+ .msg pre {
+ background: #0d0d10; padding: .7rem; border-radius: .4rem;
+ overflow-x: auto; font-size: .875rem;
+ }
+ .msg code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
+ .prompt { max-width: 46rem; margin: 0 auto 1rem; padding: .85rem; border-radius: .6rem; background: #4a3a1e; }
+ .prompt button {
+ font-size: 1rem; padding: .7rem 1.4rem; margin: .5rem .5rem 0 0;
+ border: 0; border-radius: .5rem; color: #fff; touch-action: manipulation;
+ }
+ .allow { background: #2f7d4f; }
+ .deny { background: #8c3535; }
+ form { display: flex; gap: .5rem; padding: .75rem; padding-bottom: max(.75rem, env(safe-area-inset-bottom));
+ border-top: 1px solid #2c2c33; background: #1b1b20; }
+ textarea {
+ flex: 1; resize: none; font: inherit; padding: .6rem; border-radius: .5rem;
+ border: 1px solid #35353d; background: #101014; color: inherit;
+ }
+ button.send { font-size: 1rem; padding: .6rem 1.2rem; border: 0; border-radius: .5rem; background: #3b6ea5; color: #fff; }
+ </style>
+ </head>
+ <body>
+ <div id="log"></div>
+ <form id="composer">
+ <textarea id="input" rows="1" placeholder="Message elelem…" autocapitalize="sentences"></textarea>
+ <button class="send" type="submit">Send</button>
+ </form>
+ <script>
+ const log = document.getElementById("log");
+ const input = document.getElementById("input");
+ let current = null;
+
+ const atBottom = () => log.scrollHeight - log.scrollTop - log.clientHeight < 80;
+ const scroll = (was) => { if (was) log.scrollTop = log.scrollHeight; };
+
+ function escape(text) {
+ return text.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
+ }
+
+ function render(text) {
+ return escape(text)
+ .replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (_m, _lang, code) => "<pre><code>" + code + "</code></pre>")
+ .replace(/`([^`\\n]+)`/g, "<code>$1</code>")
+ .replace(/\\*\\*([^*]+)\\*\\*/g, "<strong>$1</strong>")
+ .replace(/\\n/g, "<br>");
+ }
+
+ function bubble(cls) {
+ const was = atBottom();
+ const el = document.createElement("div");
+ el.className = "msg " + cls;
+ log.appendChild(el);
+ scroll(was);
+ return el;
+ }
+
+ function append(cls, text) {
+ if (!current || current.dataset.cls !== cls) {
+ current = bubble(cls);
+ current.dataset.cls = cls;
+ current.dataset.raw = "";
+ }
+ const was = atBottom();
+ current.dataset.raw += text;
+ current.innerHTML = render(current.dataset.raw);
+ scroll(was);
+ }
+
+ function askPermission(text) {
+ current = null;
+ const was = atBottom();
+ const el = document.createElement("div");
+ el.className = "prompt";
+ el.innerHTML = "<div>" + escape(text) + "</div>";
+ const respond = (answer) => {
+ el.querySelectorAll("button").forEach((b) => b.remove());
+ el.insertAdjacentHTML("beforeend", "<div><em>" + answer + "</em></div>");
+ fetch("/answer", { method: "POST", body: JSON.stringify({ text: answer }) });
+ };
+ const allow = document.createElement("button");
+ allow.className = "allow";
+ allow.textContent = "Allow";
+ allow.onclick = () => respond("y");
+ const deny = document.createElement("button");
+ deny.className = "deny";
+ deny.textContent = "Deny";
+ deny.onclick = () => respond("n");
+ el.append(allow, deny);
+ log.appendChild(el);
+ scroll(was);
+ }
+
+ new EventSource("/events").onmessage = (e) => {
+ const event = JSON.parse(e.data);
+ if (event.type === "content") append("assistant", event.text);
+ else if (event.type === "thinking") append("thinking", event.text);
+ else if (event.type === "prompt") askPermission(event.text);
+ else if (event.type === "done") current = null;
+ };
+
+ document.getElementById("composer").onsubmit = (e) => {
+ e.preventDefault();
+ const text = input.value.trim();
+ if (!text) return;
+ append("user", text);
+ current = null;
+ input.value = "";
+ fetch("/message", { method: "POST", body: JSON.stringify({ text: text }) });
+ };
+
+ input.addEventListener("keydown", (e) => {
+ if (e.key === "Enter" && !e.shiftKey) {
+ e.preventDefault();
+ document.getElementById("composer").requestSubmit();
+ }
+ });
+
+ input.addEventListener("input", () => {
+ input.style.height = "auto";
+ input.style.height = Math.min(input.scrollHeight, 160) + "px";
+ });
+ </script>
+ </body>
+ </html>
+ HTML
+ end
+end
lib/elelem/web_terminal.rb
@@ -0,0 +1,80 @@
+# frozen_string_literal: true
+
+module Elelem
+ class WebTerminal
+ def initialize
+ @subscribers = []
+ @mutex = Mutex.new
+ @answers = Queue.new
+ end
+
+ def subscribe
+ Queue.new.tap { |queue| @mutex.synchronize { @subscribers << queue } }
+ end
+
+ def unsubscribe(queue)
+ @mutex.synchronize { @subscribers.delete(queue) }
+ end
+
+ def interactive?
+ true
+ end
+
+ def quiet?
+ false
+ end
+
+ def say(text)
+ emit("content", text)
+ end
+
+ def print(text)
+ emit("content", text)
+ end
+
+ def think(text)
+ emit("thinking", text)
+ nil
+ end
+
+ def markdown(text)
+ text
+ end
+
+ def display_file(path, fallback: nil)
+ say(fallback || path)
+ end
+
+ def ask(prompt)
+ emit("prompt", prompt)
+ @answers.pop
+ end
+
+ def answer(text)
+ @answers << text
+ end
+
+ def done
+ broadcast(type: "done")
+ end
+
+ def waiting; end
+
+ def gap; end
+
+ def newline(n: 1); end
+
+ private
+
+ def emit(type, text)
+ plain = text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
+ return if plain.strip.empty?
+
+ broadcast(type: type, text: plain)
+ end
+
+ def broadcast(event)
+ @mutex.synchronize { @subscribers.each { |queue| queue << event } }
+ end
+ end
+end
spec/elelem/web_terminal_spec.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::WebTerminal do
+ subject { described_class.new }
+
+ let(:events) { subject.subscribe }
+
+ before { events }
+
+ def drain(queue = events)
+ [].tap { |collected| collected << queue.pop until queue.empty? }
+ end
+
+ describe "#interactive?" do
+ it { expect(subject).to be_interactive }
+ end
+
+ describe "#quiet?" do
+ it { expect(subject).not_to be_quiet }
+ end
+
+ describe "#say" do
+ it "emits content" do
+ subject.say("hello")
+ expect(drain).to eq([{ type: "content", text: "hello" }])
+ end
+
+ it "ignores blank text" do
+ subject.say(" ")
+ subject.say(nil)
+ expect(drain).to be_empty
+ end
+
+ it "strips ANSI colour codes meant for a terminal" do
+ subject.say("+ \e[36mexecute\e[0m(...)")
+ expect(drain).to eq([{ type: "content", text: "+ execute(...)" }])
+ end
+ end
+
+ describe "#think" do
+ it "emits thinking and returns nil so #print stays a no-op" do
+ expect(subject.think("pondering")).to be_nil
+ expect(drain).to eq([{ type: "thinking", text: "pondering" }])
+ end
+ end
+
+ describe "#markdown" do
+ it "passes text through for client-side rendering" do
+ expect(subject.markdown("# hi")).to eq("# hi")
+ expect(drain).to be_empty
+ end
+ end
+
+ describe "#display_file" do
+ it "emits the fallback" do
+ subject.display_file("a.rb", fallback: "contents")
+ expect(drain).to eq([{ type: "content", text: "contents" }])
+ end
+ end
+
+ describe "#done" do
+ it "emits a turn boundary" do
+ subject.done
+ expect(drain).to eq([{ type: "done" }])
+ end
+ end
+
+ describe "terminal-only concerns" do
+ it "are no-ops" do
+ subject.waiting
+ subject.gap
+ subject.newline
+ expect(drain).to be_empty
+ end
+ end
+
+ describe "multiple subscribers" do
+ it "every subscriber receives every event" do
+ other = subject.subscribe
+ subject.say("broadcast")
+ expect(drain).to eq([{ type: "content", text: "broadcast" }])
+ expect(drain(other)).to eq([{ type: "content", text: "broadcast" }])
+ end
+
+ it "stops delivering after unsubscribe" do
+ subject.unsubscribe(events)
+ subject.say("gone")
+ expect(drain).to be_empty
+ end
+ end
+
+ describe "#ask" do
+ it "emits a prompt and blocks until answered" do
+ thread = Thread.new { subject.ask("Allow?") }
+ expect(events.pop).to eq({ type: "prompt", text: "Allow?" })
+ subject.answer("n")
+ expect(thread.value).to eq("n")
+ end
+ end
+end