Commit e713eef
Changed files (9)
lib
spec
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) => ({ "&": "&", "<": "<", ">": ">" })[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) }