Commit 13e3365

mo khan <mo@mokhan.ca>
2026-01-08 06:36:02
feat: add /provider and /model slash commands
1 parent de63712
lib/elelem/agent.rb
@@ -2,12 +2,17 @@
 
 module Elelem
   class Agent
-    attr_reader :conversation, :client, :toolbox
+    PROVIDERS = %w[ollama anthropic openai vertex-ai].freeze
+    ANTHROPIC_MODELS = %w[claude-sonnet-4-20250514 claude-opus-4-20250514 claude-haiku-3-5-20241022].freeze
+    VERTEX_MODELS = %w[claude-sonnet-4@20250514 claude-opus-4-5@20251101].freeze
 
-    def initialize(client, toolbox)
+    attr_reader :conversation, :client, :toolbox, :provider
+
+    def initialize(provider, model, toolbox)
       @conversation = Conversation.new
-      @client = client
+      @provider = provider
       @toolbox = toolbox
+      @client = build_client(provider, model)
     end
 
     def repl
@@ -31,6 +36,7 @@ module Elelem
             mode = Set[:read, :execute]
             puts "  → Mode: verify (read + execute)"
           when "/mode"
+            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
@@ -38,6 +44,34 @@ module Elelem
             conversation.clear
             puts "  → Conversation cleared"
           when "/context" then puts conversation.dump(mode)
+          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
           else
             puts help_banner
           end
@@ -58,6 +92,8 @@ module Elelem
     def help_banner
       <<~HELP
   /mode auto build plan verify
+  /provider
+  /model
   /clear
   /context
   /exit
@@ -65,6 +101,53 @@ module Elelem
       HELP
     end
 
+    def build_client(provider_name, model = nil)
+      model_opts = model ? { model: model } : {}
+
+      case provider_name
+      when "ollama"     then Net::Llm::Ollama.new(**model_opts)
+      when "anthropic"  then Net::Llm::Anthropic.new(**model_opts)
+      when "openai"     then Net::Llm::OpenAI.new(**model_opts)
+      when "vertex-ai"  then Net::Llm::VertexAI.new(**model_opts)
+      else
+        raise Error, "Unknown provider: #{provider_name}"
+      end
+    end
+
+    def models_for(provider_name)
+      case provider_name
+      when "ollama"
+        client_for_models = provider_name == provider ? client : build_client(provider_name)
+        client_for_models.tags["models"]&.map { |m| m["name"] } || []
+      when "openai"
+        client_for_models = provider_name == provider ? client : build_client(provider_name)
+        client_for_models.models["data"]&.map { |m| m["id"] } || []
+      when "anthropic"
+        ANTHROPIC_MODELS
+      when "vertex-ai"
+        VERTEX_MODELS
+      else
+        []
+      end
+    rescue KeyError => e
+      puts "  ⚠ Missing credentials: #{e.message}"
+      []
+    rescue => e
+      puts "  ⚠ 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}"
+    end
+
+    def switch_model(model)
+      @client = build_client(provider, model)
+      puts "  → Switched to #{provider}/#{client.model}"
+    end
+
     def format_tool_call_result(result)
       return if result.nil?
       return result["stdout"] if result["stdout"]
@@ -97,15 +180,20 @@ module Elelem
         tool_calls = []
 
         print "Thinking> "
-        client.fetch(messages + turn_context, tools) do |chunk|
-          case chunk[:type]
-          when :delta
-            print chunk[:thinking] if chunk[:thinking]
-            content += chunk[:content] if chunk[:content]
-          when :complete
-            content = chunk[:content] if chunk[:content]
-            tool_calls = chunk[:tool_calls] || []
+        begin
+          client.fetch(messages + turn_context, tools) do |chunk|
+            case chunk[:type]
+            when :delta
+              print chunk[:thinking] if chunk[:thinking]
+              content += chunk[:content] if chunk[:content]
+            when :complete
+              content = chunk[:content] if chunk[:content]
+              tool_calls = chunk[:tool_calls] || []
+            end
           end
+        rescue => e
+          puts "\n  ✗ API Error: #{e.message}"
+          return { role: "assistant", content: "[Error: #{e.message}]" }
         end
 
         puts "\nAssistant> #{content}" unless content.to_s.empty?
lib/elelem/application.rb
@@ -15,27 +15,13 @@ module Elelem
                   type: :string,
                   desc: "Model name (uses provider default if not specified)"
     def chat(*)
-      client = build_client
-      say "Agent (#{options[:provider]}/#{client.model})", :green
-      agent = Agent.new(client, Toolbox.new)
+      provider = options[:provider]
+      model = options[:model]
+      say "Agent (#{provider})", :green
+      agent = Agent.new(provider, model, Toolbox.new)
       agent.repl
     end
 
-    private
-
-    def build_client
-      model_opts = options[:model] ? { model: options[:model] } : {}
-
-      case options[:provider]
-      when "ollama"     then Net::Llm::Ollama.new(**model_opts)
-      when "anthropic"  then Net::Llm::Anthropic.new(**model_opts)
-      when "openai"     then Net::Llm::OpenAI.new(**model_opts)
-      when "vertex-ai"  then Net::Llm::VertexAI.new(**model_opts)
-      else
-        raise Error, "Unknown provider: #{options[:provider]}. Use: #{PROVIDERS.join(', ')}"
-      end
-    end
-
     desc "files", "Generate CXML of the files"
     def files
       puts '<documents>'
lib/elelem.rb
@@ -1,5 +1,6 @@
 # frozen_string_literal: true
 
+require "cli/ui"
 require "erb"
 require "fileutils"
 require "json"
spec/elelem/agent_spec.rb
@@ -1,8 +1,15 @@
 # frozen_string_literal: true
 
 RSpec.describe Elelem::Agent do
-  let(:mock_client) { double("client") }
-  let(:agent) { described_class.new(mock_client, Elelem::Toolbox.new) }
+  let(:mock_client) { double("client", model: "test-model") }
+  let(:agent) do
+    agent = described_class.allocate
+    agent.instance_variable_set(:@conversation, Elelem::Conversation.new)
+    agent.instance_variable_set(:@provider, "ollama")
+    agent.instance_variable_set(:@toolbox, Elelem::Toolbox.new)
+    agent.instance_variable_set(:@client, mock_client)
+    agent
+  end
 
   describe "#initialize" do
     it "creates a new conversation" do
elelem.gemspec
@@ -38,6 +38,7 @@ Gem::Specification.new do |spec|
   spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
   spec.require_paths = ["lib"]
 
+  spec.add_dependency "cli-ui", "~> 2.0"
   spec.add_dependency "erb", "~> 6.0"
   spec.add_dependency "fileutils", "~> 1.0"
   spec.add_dependency "json", "~> 2.0"
Gemfile.lock
@@ -2,6 +2,7 @@ PATH
   remote: .
   specs:
     elelem (0.5.0)
+      cli-ui (~> 2.0)
       erb (~> 6.0)
       fileutils (~> 1.0)
       json (~> 2.0)
@@ -22,6 +23,7 @@ GEM
       public_suffix (>= 2.0.2, < 7.0)
     base64 (0.3.0)
     bigdecimal (3.2.2)
+    cli-ui (2.7.0)
     date (3.4.1)
     diff-lcs (1.6.2)
     erb (6.0.1)