Commit e713eef

mo khan <mo@mokhan.ca>
2026-08-02 21:59:28
feat: add elelem serve command
1 parent 64ccd4a
lib/elelem/cli.rb
@@ -6,6 +6,7 @@ module Elelem
   class CLI
     def initialize(args)
       @provider = "ollama"
+      @port = 4567
       @args = parse(args)
     end
 
@@ -25,9 +26,11 @@ module Elelem
         o.separator "  chat              Interactive REPL (default)"
         o.separator "  ask <prompt>      One-shot query (reads stdin if piped)"
         o.separator "  files             Output files as XML (no options)"
+        o.separator "  server            Serve the web UI on the local network"
         o.separator "  help              Show this help"
         o.separator "\nOptions:"
         o.on("-p", "--provider NAME", "ollama, anthropic, vertex, openai") { |p| @provider = p }
+        o.on("-P", "--port PORT", Integer, "Port for the server command (default 4567)") { |p| @port = p }
         o.on("-h", "--help") { puts o; exit }
       end
       @parser.parse!(args)
@@ -41,6 +44,10 @@ module Elelem
       Elelem.start(provider: @provider)
     end
 
+    def server
+      Elelem.serve(provider: @provider, port: @port)
+    end
+
     def ask
       abort "Usage: elelem ask <prompt>" if @args.empty?
       prompt = @args.join(" ")
lib/elelem/permissions.rb
@@ -32,7 +32,7 @@ module Elelem
     end
 
     def prompt(tool_name, args, terminal)
-      return true unless $stdin.tty?
+      return true unless terminal.interactive?
 
       answer = terminal.ask("  Allow? [Y/n] > ")&.downcase
       raise "User denied permission: #{tool_name}" if answer == "n"
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;" })[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/terminal.rb
@@ -14,6 +14,10 @@ module Elelem
       @quiet
     end
 
+    def interactive?
+      $stdin.tty?
+    end
+
     def ask(prompt)
       Reline.readline(prompt, true)&.strip
     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
lib/elelem.rb
@@ -28,11 +28,13 @@ require_relative "elelem/net"
 require_relative "elelem/permissions"
 require_relative "elelem/plugins"
 require_relative "elelem/providers"
+require_relative "elelem/server"
 require_relative "elelem/system_prompt"
 require_relative "elelem/terminal"
 require_relative "elelem/tool"
 require_relative "elelem/toolbox"
 require_relative "elelem/version"
+require_relative "elelem/web_terminal"
 
 module Elelem
   def self.sh(cmd, args: [], cwd: Dir.pwd, env: {})
@@ -57,6 +59,13 @@ module Elelem
     agent.repl
   end
 
+  def self.serve(provider: "ollama", port: 4567, toolbox: Toolbox.new)
+    client = Providers.build(provider)
+    agent = Agent.new(client, toolbox: toolbox, terminal: WebTerminal.new)
+    Plugins.setup!(agent)
+    Server.new(agent, port: port).start
+  end
+
   def self.ask(prompt, provider: "ollama", toolbox: Toolbox.new)
     client = Providers.build(provider)
     agent = Agent.new(client, toolbox: toolbox, terminal: Terminal.new(quiet: true))
spec/elelem/permissions_spec.rb
@@ -3,7 +3,7 @@
 RSpec.describe Elelem::Permissions do
   subject { described_class.new }
 
-  let(:terminal) { double(ask: nil) }
+  let(:terminal) { double(ask: nil, interactive?: false) }
 
   describe "#check" do
     context "with default allow policies" do
@@ -19,11 +19,29 @@ RSpec.describe Elelem::Permissions do
       it { expect { subject.check("write", {}, terminal: terminal) }.to raise_error(/Permission denied/) }
     end
 
-    context "with ask policy in non-TTY mode" do
-      before { allow($stdin).to receive(:tty?).and_return(false) }
-
+    context "with ask policy on a non-interactive terminal" do
       it "returns true without prompting" do
         expect(subject.check("execute", {}, terminal: terminal)).to be true
+        expect(terminal).not_to have_received(:ask)
+      end
+    end
+
+    context "with ask policy on an interactive terminal" do
+      let(:terminal) { double(ask: answer, interactive?: true) }
+
+      context "when approved" do
+        let(:answer) { "y" }
+
+        it "prompts and returns true" do
+          expect(subject.check("execute", {}, terminal: terminal)).to be true
+          expect(terminal).to have_received(:ask)
+        end
+      end
+
+      context "when denied" do
+        let(:answer) { "n" }
+
+        it { expect { subject.check("execute", {}, terminal: terminal) }.to raise_error(/User denied permission/) }
       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
elelem.gemspec
@@ -51,11 +51,13 @@ Gem::Specification.new do |spec|
     "lib/elelem/plugins/write.rb",
     "lib/elelem/prompts/default.erb",
     "lib/elelem/providers.rb",
+    "lib/elelem/server.rb",
     "lib/elelem/system_prompt.rb",
     "lib/elelem/terminal.rb",
     "lib/elelem/tool.rb",
     "lib/elelem/toolbox.rb",
     "lib/elelem/version.rb",
+    "lib/elelem/web_terminal.rb",
   ]
   spec.bindir = "exe"
   spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }