Commit cb5b001

mo khan <mo@mokhan.ca>
2026-01-12 20:35:26
refactor: extract a Terminal class tag: v0.6.0
1 parent dd05d3d
lib/elelem/agent.rb
@@ -9,110 +9,29 @@ module Elelem
     MODES = %w[auto build plan verify].freeze
     ENV_VARS = %w[ANTHROPIC_API_KEY OPENAI_API_KEY OPENAI_BASE_URL OLLAMA_HOST GOOGLE_CLOUD_PROJECT GOOGLE_CLOUD_REGION].freeze
 
-    attr_reader :conversation, :client, :toolbox, :provider
+    attr_reader :conversation, :client, :toolbox, :provider, :terminal
 
-    def initialize(provider, model, toolbox)
+    def initialize(provider, model, toolbox, terminal: nil)
       @conversation = Conversation.new
       @provider = provider
       @toolbox = toolbox
       @client = build_client(provider, model)
+      @terminal = terminal || Terminal.new(
+        commands: COMMANDS,
+        modes: MODES,
+        providers: PROVIDERS,
+        env_vars: ENV_VARS
+      )
     end
 
     def repl
-      Reline.autocompletion = true
-      Reline.completion_proc = ->(target, preposing) { complete(target, preposing) }
       mode = Set.new([:read])
 
       loop do
-        input = ask?("User> ")
+        input = terminal.ask("User> ")
         break if input.nil?
         if input.start_with?("/")
-          case input
-          when "/mode auto"
-            mode = Set[:read, :write, :execute]
-            puts "  → Mode: auto (all tools enabled)"
-          when "/mode build"
-            mode = Set[:read, :write]
-            puts "  → Mode: build (read + write)"
-          when "/mode plan"
-            mode = Set[:read]
-            puts "  → Mode: plan (read-only)"
-          when "/mode verify"
-            mode = Set[:read, :execute]
-            puts "  → Mode: verify (read + execute)"
-          when "/mode"
-            puts "  Usage: /mode [auto|build|plan|verify]"
-            puts ""
-            puts "  Provider: #{provider}/#{client.model}"
-            puts "  Mode: #{mode.to_a.inspect}"
-            puts "  Tools: #{toolbox.tools_for(mode).map { |t| t.dig(:function, :name) }}"
-          when "/exit" then exit
-          when "/clear"
-            conversation.clear
-            puts "  → Conversation cleared"
-          when "/context" then puts conversation.dump(mode)
-          when "/shell"
-            transcript = start_shell
-            conversation.add(role: :user, content: transcript) unless transcript.strip.empty?
-            puts "  → Shell session captured"
-          when "/provider"
-            CLI::UI::Prompt.ask("Provider?") do |handler|
-              PROVIDERS.each do |name|
-                handler.option(name) do |selected_provider|
-                  models = models_for(selected_provider)
-                  if models.empty?
-                    puts "  ✗ No models available for #{selected_provider}"
-                  else
-                    CLI::UI::Prompt.ask("Model?") do |h|
-                      models.each do |model|
-                        h.option(model) { |m| switch_client(selected_provider, m) }
-                      end
-                    end
-                  end
-                end
-              end
-            end
-          when "/model"
-            models = models_for(provider)
-            if models.empty?
-              puts "  ✗ No models available for #{provider}"
-            else
-              CLI::UI::Prompt.ask("Model?") do |handler|
-                models.each do |model|
-                  handler.option(model) { |m| switch_model(m) }
-                end
-              end
-            end
-          when "/env"
-            puts "  Usage: /env VAR cmd..."
-            puts ""
-            ENV_VARS.each do |var|
-              value = ENV[var]
-              if value
-                masked = value.length > 8 ? "#{value[0..3]}...#{value[-4..]}" : "****"
-                puts "  #{var}=#{masked}"
-              else
-                puts "  #{var}=(not set)"
-              end
-            end
-          when %r{^/env\s+(\w+)\s+(.+)$}
-            var_name = $1
-            command = $2
-            result = Elelem.shell.execute("sh", args: ["-c", command])
-            if result["exit_status"].zero?
-              value = result["stdout"].lines.first&.strip
-              if value && !value.empty?
-                ENV[var_name] = value
-                puts "  → Set #{var_name}"
-              else
-                puts "  ⚠ Command produced no output"
-              end
-            else
-              puts "  ⚠ Command failed: #{result['stderr']}"
-            end
-          else
-            puts help_banner
-          end
+          handle_command(input, mode)
         else
           conversation.add(role: :user, content: input)
           result = execute_turn(conversation.history_for(mode), tools: toolbox.tools_for(mode))
@@ -123,53 +42,88 @@ module Elelem
 
     private
 
-    def ask?(text)
-      Reline.readline(text, true)&.strip
-    end
-
-    def complete(target, preposing)
-      line = "#{preposing}#{target}"
-
-      if line.start_with?('/') && !preposing.include?(' ')
-        return COMMANDS.select { |c| c.start_with?(line) }
-      end
-
-      case preposing.strip
-      when '/mode'
-        MODES.select { |m| m.start_with?(target) }
-      when '/provider'
-        PROVIDERS.select { |p| p.start_with?(target) }
-      when '/env'
-        ENV_VARS.select { |v| v.start_with?(target) }
-      when %r{^/env\s+\w+\s+pass(\s+show)?\s*$}
-        subcommands = %w[show ls insert generate edit rm]
-        matches = subcommands.select { |c| c.start_with?(target) }
-        matches.any? ? matches : complete_pass_entries(target)
-      when %r{^/env\s+\w+$}
-        complete_commands(target)
+    def handle_command(input, mode)
+      case input
+      when "/mode auto"
+        mode.replace([:read, :write, :execute])
+        terminal.say "  → Mode: auto (all tools enabled)"
+      when "/mode build"
+        mode.replace([:read, :write])
+        terminal.say "  → Mode: build (read + write)"
+      when "/mode plan"
+        mode.replace([:read])
+        terminal.say "  → Mode: plan (read-only)"
+      when "/mode verify"
+        mode.replace([:read, :execute])
+        terminal.say "  → Mode: verify (read + execute)"
+      when "/mode"
+        terminal.say "  Usage: /mode [auto|build|plan|verify]"
+        terminal.say ""
+        terminal.say "  Provider: #{provider}/#{client.model}"
+        terminal.say "  Mode: #{mode.to_a.inspect}"
+        terminal.say "  Tools: #{toolbox.tools_for(mode).map { |t| t.dig(:function, :name) }}"
+      when "/exit" then exit
+      when "/clear"
+        conversation.clear
+        terminal.say "  → Conversation cleared"
+      when "/context"
+        terminal.say conversation.dump(mode)
+      when "/shell"
+        transcript = start_shell
+        conversation.add(role: :user, content: transcript) unless transcript.strip.empty?
+        terminal.say "  → Shell session captured"
+      when "/provider"
+        terminal.select("Provider?", PROVIDERS) do |selected_provider|
+          models = models_for(selected_provider)
+          if models.empty?
+            terminal.say "  ✗ No models available for #{selected_provider}"
+          else
+            terminal.select("Model?", models) do |m|
+              switch_client(selected_provider, m)
+            end
+          end
+        end
+      when "/model"
+        models = models_for(provider)
+        if models.empty?
+          terminal.say "  ✗ No models available for #{provider}"
+        else
+          terminal.select("Model?", models) do |m|
+            switch_model(m)
+          end
+        end
+      when "/env"
+        terminal.say "  Usage: /env VAR cmd..."
+        terminal.say ""
+        ENV_VARS.each do |var|
+          value = ENV[var]
+          if value
+            masked = value.length > 8 ? "#{value[0..3]}...#{value[-4..]}" : "****"
+            terminal.say "  #{var}=#{masked}"
+          else
+            terminal.say "  #{var}=(not set)"
+          end
+        end
+      when %r{^/env\s+(\w+)\s+(.+)$}
+        var_name = $1
+        command = $2
+        result = Elelem.shell.execute("sh", args: ["-c", command])
+        if result["exit_status"].zero?
+          value = result["stdout"].lines.first&.strip
+          if value && !value.empty?
+            ENV[var_name] = value
+            terminal.say "  → Set #{var_name}"
+          else
+            terminal.say "  ⚠ Command produced no output"
+          end
+        else
+          terminal.say "  ⚠ Command failed: #{result['stderr']}"
+        end
       else
-        complete_files(target)
+        terminal.say help_banner
       end
     end
 
-    def complete_commands(target)
-      result = Elelem.shell.execute("bash", args: ["-c", "compgen -c #{target}"])
-      result["stdout"].lines.map(&:strip).first(20)
-    end
-
-    def complete_files(target)
-      result = Elelem.shell.execute("bash", args: ["-c", "compgen -f #{target}"])
-      result["stdout"].lines.map(&:strip).first(20)
-    end
-
-    def complete_pass_entries(target)
-      store = ENV.fetch("PASSWORD_STORE_DIR", File.expand_path("~/.password-store"))
-      result = Elelem.shell.execute("find", args: ["-L", store, "-name", "*.gpg"])
-      result["stdout"].lines.map { |l|
-        l.strip.sub("#{store}/", "").sub(/\.gpg$/, "")
-      }.select { |e| e.start_with?(target) }.first(20)
-    end
-
     def strip_ansi(text)
       text.gsub(/^Script started.*?\n/, '')
           .gsub(/\nScript done.*$/, '')
@@ -229,22 +183,22 @@ module Elelem
         []
       end
     rescue KeyError => e
-      puts "  ⚠ Missing credentials: #{e.message}"
+      terminal.say "  ⚠ Missing credentials: #{e.message}"
       []
     rescue => e
-      puts "  ⚠ Could not fetch models: #{e.message}"
+      terminal.say "  ⚠ Could not fetch models: #{e.message}"
       []
     end
 
     def switch_client(new_provider, model)
       @provider = new_provider
       @client = build_client(new_provider, model)
-      puts "  → Switched to #{new_provider}/#{client.model}"
+      terminal.say "  → Switched to #{new_provider}/#{client.model}"
     end
 
     def switch_model(model)
       @client = build_client(provider, model)
-      puts "  → Switched to #{provider}/#{client.model}"
+      terminal.say "  → Switched to #{provider}/#{client.model}"
     end
 
     def format_tool_call_result(result)
@@ -290,12 +244,12 @@ module Elelem
         content = ""
         tool_calls = []
 
-        print "Thinking... "
+        terminal.write "Thinking... "
         begin
           client.fetch(messages + turn_context, tools) do |chunk|
             case chunk[:type]
             when :delta
-              print chunk[:thinking] if chunk[:thinking]
+              terminal.write chunk[:thinking] if chunk[:thinking]
               content += chunk[:content] if chunk[:content]
             when :complete
               content = chunk[:content] if chunk[:content]
@@ -303,20 +257,20 @@ module Elelem
             end
           end
         rescue => e
-          puts "\n  ✗ API Error: #{e.message}"
+          terminal.say "\n  ✗ API Error: #{e.message}"
           return { role: "assistant", content: "[Error: #{e.message}]" }
         end
 
-        puts "\nAssistant> #{content}" unless content.to_s.empty?
+        terminal.say "\nAssistant> #{content}" unless content.to_s.empty?
         api_tool_calls = tool_calls.any? ? format_tool_calls_for_api(tool_calls) : nil
         turn_context << { role: "assistant", content: content, tool_calls: api_tool_calls }.compact
 
         if tool_calls.any?
           tool_calls.each do |call|
             name, args = call[:name], call[:arguments]
-            puts "\nTool> #{name}(#{args})"
+            terminal.say "\nTool> #{name}(#{args})"
             result = toolbox.run_tool(name, args)
-            puts truncate_output(format_tool_call_result(result))
+            terminal.say truncate_output(format_tool_call_result(result))
             turn_context << { role: "tool", tool_call_id: call[:id], content: JSON.dump(result) }
             errors += 1 if result[:error]
           end
lib/elelem/terminal.rb
@@ -0,0 +1,83 @@
+# frozen_string_literal: true
+
+module Elelem
+  class Terminal
+    def initialize(commands: [], modes: [], providers: [], env_vars: [])
+      @commands = commands
+      @modes = modes
+      @providers = providers
+      @env_vars = env_vars
+      setup_completion
+    end
+
+    def ask(prompt)
+      Reline.readline(prompt, true)&.strip
+    end
+
+    def say(message)
+      $stdout.puts message
+    end
+
+    def write(message)
+      $stdout.print message
+    end
+
+    def select(question, options, &block)
+      CLI::UI::Prompt.ask(question) do |handler|
+        options.each do |option|
+          handler.option(option) { |selected| block.call(selected) }
+        end
+      end
+    end
+
+    private
+
+    def setup_completion
+      Reline.autocompletion = true
+      Reline.completion_proc = ->(target, preposing) { complete(target, preposing) }
+    end
+
+    def complete(target, preposing)
+      line = "#{preposing}#{target}"
+
+      if line.start_with?('/') && !preposing.include?(' ')
+        return @commands.select { |c| c.start_with?(line) }
+      end
+
+      case preposing.strip
+      when '/mode'
+        @modes.select { |m| m.start_with?(target) }
+      when '/provider'
+        @providers.select { |p| p.start_with?(target) }
+      when '/env'
+        @env_vars.select { |v| v.start_with?(target) }
+      when %r{^/env\s+\w+\s+pass(\s+show)?\s*$}
+        subcommands = %w[show ls insert generate edit rm]
+        matches = subcommands.select { |c| c.start_with?(target) }
+        matches.any? ? matches : complete_pass_entries(target)
+      when %r{^/env\s+\w+$}
+        complete_commands(target)
+      else
+        complete_files(target)
+      end
+    end
+
+    def complete_commands(target)
+      result = Elelem.shell.execute("bash", args: ["-c", "compgen -c #{target}"])
+      result["stdout"].lines.map(&:strip).first(20)
+    end
+
+    def complete_files(target)
+      result = Elelem.shell.execute("bash", args: ["-c", "compgen -f #{target}"])
+      result["stdout"].lines.map(&:strip).first(20)
+    end
+
+    def complete_pass_entries(target)
+      store = ENV.fetch("PASSWORD_STORE_DIR", File.expand_path("~/.password-store"))
+      result = Elelem.shell.execute("find", args: ["-L", store, "-name", "*.gpg"])
+      result["stdout"].lines.map { |l|
+        l.strip.sub("#{store}/", "").sub(/\.gpg$/, "")
+      }.select { |e| e.start_with?(target) }.first(20)
+    end
+  end
+end
lib/elelem.rb
@@ -17,6 +17,7 @@ require "timeout"
 require_relative "elelem/agent"
 require_relative "elelem/application"
 require_relative "elelem/conversation"
+require_relative "elelem/terminal"
 require_relative "elelem/tool"
 require_relative "elelem/toolbox"
 require_relative "elelem/version"
spec/elelem/agent_e2e_spec.rb
@@ -0,0 +1,105 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Agent do
+  let(:toolbox) { Elelem::Toolbox.new }
+  let(:fake_client) { instance_double(Net::Llm::Ollama, model: "test-model") }
+
+  before do
+    allow(Net::Llm::Ollama).to receive(:new).and_return(fake_client)
+  end
+
+  describe "slash commands" do
+    describe "/mode" do
+      it "shows help when called without arguments" do
+        terminal = Elelem::FakeTerminal.new(inputs: ["/mode", nil])
+        agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+
+        agent.repl
+
+        expect(terminal.output).to include("  Usage: /mode [auto|build|plan|verify]")
+      end
+
+      it "switches to auto mode" do
+        terminal = Elelem::FakeTerminal.new(inputs: ["/mode auto", nil])
+        agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+
+        agent.repl
+
+        expect(terminal.output).to include("  → Mode: auto (all tools enabled)")
+      end
+
+      it "switches to build mode" do
+        terminal = Elelem::FakeTerminal.new(inputs: ["/mode build", nil])
+        agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+
+        agent.repl
+
+        expect(terminal.output).to include("  → Mode: build (read + write)")
+      end
+
+      it "switches to plan mode" do
+        terminal = Elelem::FakeTerminal.new(inputs: ["/mode plan", nil])
+        agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+
+        agent.repl
+
+        expect(terminal.output).to include("  → Mode: plan (read-only)")
+      end
+
+      it "switches to verify mode" do
+        terminal = Elelem::FakeTerminal.new(inputs: ["/mode verify", nil])
+        agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+
+        agent.repl
+
+        expect(terminal.output).to include("  → Mode: verify (read + execute)")
+      end
+    end
+
+    describe "/clear" do
+      it "clears the conversation" do
+        terminal = Elelem::FakeTerminal.new(inputs: ["/clear", nil])
+        agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+        agent.conversation.add(role: :user, content: "hello")
+
+        agent.repl
+
+        expect(terminal.output).to include("  → Conversation cleared")
+      end
+    end
+
+    describe "/env" do
+      it "shows help and env vars when called without arguments" do
+        terminal = Elelem::FakeTerminal.new(inputs: ["/env", nil])
+        agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+
+        agent.repl
+
+        expect(terminal.output).to include("  Usage: /env VAR cmd...")
+        expect(terminal.output.any? { |line| line.include?("ANTHROPIC_API_KEY") }).to be true
+      end
+
+      it "sets environment variable from command output" do
+        terminal = Elelem::FakeTerminal.new(inputs: ["/env TEST_VAR echo hello", nil])
+        agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+
+        agent.repl
+
+        expect(terminal.output).to include("  → Set TEST_VAR")
+        expect(ENV["TEST_VAR"]).to eq("hello")
+      end
+    end
+
+    describe "/help" do
+      it "shows help banner" do
+        terminal = Elelem::FakeTerminal.new(inputs: ["/help", nil])
+        agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
+
+        agent.repl
+
+        expect(terminal.output.join).to include("/env VAR cmd...")
+        expect(terminal.output.join).to include("/mode auto build plan verify")
+      end
+    end
+  end
+end
spec/support/fake_terminal.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Elelem
+  class FakeTerminal
+    attr_reader :output, :selections
+
+    def initialize(inputs: [], selections: {})
+      @inputs = inputs
+      @selections = selections
+      @output = []
+    end
+
+    def ask(_prompt)
+      @inputs.shift
+    end
+
+    def say(message)
+      @output << message
+    end
+
+    def write(message)
+      @output << message
+    end
+
+    def select(question, _options, &block)
+      selected = @selections[question]
+      block.call(selected) if selected
+    end
+  end
+end
spec/spec_helper.rb
@@ -2,6 +2,8 @@
 
 require_relative "../lib/elelem"
 
+Dir[File.join(__dir__, "support/**/*.rb")].each { |f| require f }
+
 RSpec.configure do |config|
   # Enable flags like --only-failures and --next-failure
   config.example_status_persistence_file_path = ".rspec_status"
CHANGELOG.md
@@ -1,5 +1,7 @@
 ## [Unreleased]
 
+## [0.6.0] - 2026-01-12
+
 ### Added
 - `/env` slash command to capture environment variables for provider connections
 - `/shell` slash command
@@ -8,13 +10,18 @@
 - Help output for `/mode` and `/env` commands
 
 ### Changed
+- Renamed `bash` tool to `exec`
 - Tuned system prompt
 - Changed thinking prompt to ellipsis
 - Removed username from system prompt
 - Use pessimistic constraint on net-llm dependency
+- Extracted Terminal class for IO abstraction (enables E2E testing)
 
 ### Fixed
 - Prevent infinite looping errors
+- Provide function schema when tool is called with invalid arguments
+- Tab completion for `pass` entries without requiring `show` subcommand
+- Password store symlink support in tab completion
 
 ## [0.5.0] - 2025-01-07
 
elelem.gemspec
@@ -30,6 +30,7 @@ Gem::Specification.new do |spec|
     "lib/elelem/application.rb",
     "lib/elelem/conversation.rb",
     "lib/elelem/system_prompt.erb",
+    "lib/elelem/terminal.rb",
     "lib/elelem/tool.rb",
     "lib/elelem/toolbox.rb",
     "lib/elelem/version.rb",