Comparing changes

v0.5.0 v0.6.0
19 commits 17 files changed

Commits

cb5b001 refactor: extract a Terminal class mo khan 2026-01-12 20:35:26
dd05d3d refactor: rename bash to exec mo khan 2026-01-12 20:21:51
6500e07 chore: bump version mo khan 2026-01-12 20:09:12
2c9f578 fix: follow symlinks using find command mo khan 2026-01-12 20:09:02
b5dc168 chore: bump version mo khan 2026-01-12 20:07:27
c22fe94 chore: update CHANGELOG.md mo khan 2026-01-12 19:44:35
8a2d99a chore: update gems mo khan 2026-01-12 19:40:04
8f2f0be fix: prevent infinite looping errors mo khan 2026-01-09 23:30:48
8735a96 feat: print help for /mode mo khan 2026-01-09 23:05:49
818e0d2 feat: print help for /env command mo khan 2026-01-09 23:03:16
608c8b6 Add tab completion for /env mo khan 2026-01-09 22:56:20
698218b feat: add tab completion mo khan 2026-01-09 22:52:05
79343dc feat: add /shell slash command mo khan 2026-01-09 20:51:56
46b6d55 Change thinking prompt to elipsis mo khan 2026-01-08 23:14:31
f4e9fb3 feat: tune the system prompt mo khan 2026-01-08 22:46:40
lib/elelem/agent.rb
@@ -2,45 +2,36 @@
 
 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
+    COMMANDS = %w[/env /mode /provider /model /shell /clear /context /exit /help].freeze
+    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
 
-    def initialize(client, toolbox)
+    attr_reader :conversation, :client, :toolbox, :provider, :terminal
+
+    def initialize(provider, model, toolbox, terminal: nil)
       @conversation = Conversation.new
-      @client = client
+      @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
       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 "  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)
-          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))
@@ -51,13 +42,111 @@ module Elelem
 
     private
 
-    def ask?(text)
-      Reline.readline(text, true)&.strip
+    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
+        terminal.say help_banner
+      end
+    end
+
+    def strip_ansi(text)
+      text.gsub(/^Script started.*?\n/, '')
+          .gsub(/\nScript done.*$/, '')
+          .gsub(/\e\[[0-9;]*[a-zA-Z]/, '')
+          .gsub(/\e\[\?[0-9]+[hl]/, '')
+          .gsub(/[\b]/, '')
+          .gsub(/\r/, '')
+    end
+
+    def start_shell
+      Tempfile.create do |file|
+        system("script -q #{file.path}", chdir: Dir.pwd)
+        strip_ansi(File.read(file.path))
+      end
     end
 
     def help_banner
       <<~HELP
+  /env VAR cmd...
   /mode auto build plan verify
+  /provider
+  /model
+  /shell
   /clear
   /context
   /exit
@@ -65,6 +154,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
+      terminal.say "  ⚠ Missing credentials: #{e.message}"
+      []
+    rescue => e
+      terminal.say "  ⚠ Could not fetch models: #{e.message}"
+      []
+    end
+
+    def switch_client(new_provider, model)
+      @provider = new_provider
+      @client = build_client(new_provider, model)
+      terminal.say "  → Switched to #{new_provider}/#{client.model}"
+    end
+
+    def switch_model(model)
+      @client = build_client(provider, model)
+      terminal.say "  → Switched to #{provider}/#{client.model}"
+    end
+
     def format_tool_call_result(result)
       return if result.nil?
       return result["stdout"] if result["stdout"]
@@ -74,6 +210,17 @@ module Elelem
       ""
     end
 
+    def truncate_output(text, max_lines: 30)
+      return text if text.nil? || text.empty?
+
+      lines = text.to_s.lines
+      if lines.size > max_lines
+        lines.first(max_lines).join + "\n... (#{lines.size - max_lines} more lines)"
+      else
+        text
+      end
+    end
+
     def format_tool_calls_for_api(tool_calls)
       tool_calls.map do |tc|
         args = openai_client? ? JSON.dump(tc[:arguments]) : tc[:arguments]
@@ -91,39 +238,43 @@ module Elelem
 
     def execute_turn(messages, tools:)
       turn_context = []
+      errors = 0
 
       loop do
         content = ""
         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] || []
+        terminal.write "Thinking... "
+        begin
+          client.fetch(messages + turn_context, tools) do |chunk|
+            case chunk[:type]
+            when :delta
+              terminal.write 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
+          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 = call[:name]
-            args = call[:arguments]
-
-            puts "\nTool> #{name}(#{args})"
+            name, args = call[:name], call[:arguments]
+            terminal.say "\nTool> #{name}(#{args})"
             result = toolbox.run_tool(name, args)
-            puts 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
-
-          tool_calls = []
+          return { role: "assistant", content: "[Stopped: too many errors]" } if errors >= 3
           next
         end
 
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/system_prompt.erb
@@ -1,15 +1,12 @@
-You are a reasoning coding and system agent.
+You are a trusted terminal agent. You act on behalf of the user - executing tasks directly through bash, files, and git. Be capable, be direct, be done.
+
+## Principles
+
+- Act, don't explain. Execute the task.
+- Read before write. Understand existing code first.
+- Small focused changes. One thing at a time.
+- Verify your work. Run tests, check output.
 
 ## System
 
-Operating System: <%= `uname -a` %>
-USER: <%= ENV['USER'] %>
-HOME: <%= ENV['HOME'] %>
-SHELL: <%= ENV['SHELL'] %>
-PATH: <%= ENV['PATH'] %>
-PWD: <%= ENV['PWD'] %>
-LANG: <%= ENV['LANG'] %>
-EDITOR: <%= ENV['EDITOR'] %>
-LOGNAME: <%= ENV['LOGNAME'] %>
-TERM: <%= ENV['TERM'] %>
-MAIL: <%= ENV['MAIL'] %>
+<%= `uname -s`.strip %> · <%= ENV['PWD'] %>
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/tool.rb
@@ -12,7 +12,9 @@ module Elelem
 
     def call(args)
       unless valid?(args)
-        return { error: "Invalid args for #{@name}", received: args.keys, expected: @schema.dig(:function, :parameters, :required) }
+        actual = args.keys
+        expected = @schema.dig(:function, :parameters)
+        return { error: "Invalid args for #{@name}.", actual: actual, expected: expected }
       end
 
       @block.call(args)
lib/elelem/toolbox.rb
@@ -9,7 +9,7 @@ module Elelem
       full_path.exist? ? { content: full_path.read } : { error: "File not found: #{path}" }
     end
 
-    BASH_TOOL = Tool.build("bash", "Run shell commands. For git: bash({\"cmd\": \"git\", \"args\": [\"log\", \"--oneline\"]}). Returns stdout/stderr/exit_status.", { cmd: { type: "string" }, args: { type: "array", items: { type: "string" } }, env: { type: "object", additionalProperties: { type: "string" } }, cwd: { type: "string", description: "Working directory (defaults to current)" }, stdin: { type: "string" } }, ["cmd"]) do |args|
+    EXEC_TOOL = Tool.build("exec", "Run shell commands. Returns stdout/stderr/exit_status.", { cmd: { type: "string" }, args: { type: "array", items: { type: "string" } }, env: { type: "object", additionalProperties: { type: "string" } }, cwd: { type: "string", description: "Working directory (defaults to current)" }, stdin: { type: "string" } }, ["cmd"]) do |args|
       Elelem.shell.execute(
         args["cmd"],
         args: args["args"] || [],
@@ -38,10 +38,11 @@ module Elelem
     end
 
     TOOL_ALIASES = {
-      "exec" => "bash",
-      "execute" => "bash",
+      "bash" => "exec",
+      "execute" => "exec",
       "open" => "read",
       "search" => "grep",
+      "sh" => "exec",
     }
 
     attr_reader :tools
@@ -50,7 +51,7 @@ module Elelem
       @tools_by_name = {}
       @tools = { read: [], write: [], execute: [] }
       add_tool(eval_tool(binding), :execute)
-      add_tool(BASH_TOOL, :execute)
+      add_tool(EXEC_TOOL, :execute)
       add_tool(GREP_TOOL, :read)
       add_tool(LIST_TOOL, :read)
       add_tool(PATCH_TOOL, :write)
lib/elelem/version.rb
@@ -1,5 +1,5 @@
 # frozen_string_literal: true
 
 module Elelem
-  VERSION = "0.5.0"
-end
+  VERSION = "0.6.0"
+end
\ No newline at end of file
lib/elelem.rb
@@ -1,5 +1,6 @@
 # frozen_string_literal: true
 
+require "cli/ui"
 require "erb"
 require "fileutils"
 require "json"
@@ -16,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/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
spec/elelem/toolbox_spec.rb
@@ -10,7 +10,7 @@ RSpec.describe Elelem::Toolbox do
 
       tool_names = tools.map { |t| t.dig(:function, :name) }
       expect(tool_names).to include("grep", "list", "read")
-      expect(tool_names).not_to include("write", "patch", "bash")
+      expect(tool_names).not_to include("write", "patch", "exec")
     end
 
     it "returns write tools for write mode" do
@@ -19,7 +19,7 @@ RSpec.describe Elelem::Toolbox do
 
       tool_names = tools.map { |t| t.dig(:function, :name) }
       expect(tool_names).to include("patch", "write")
-      expect(tool_names).not_to include("grep", "bash")
+      expect(tool_names).not_to include("grep", "exec")
     end
 
     it "returns execute tools for execute mode" do
@@ -27,7 +27,7 @@ RSpec.describe Elelem::Toolbox do
       tools = subject.tools_for(mode)
 
       tool_names = tools.map { |t| t.dig(:function, :name) }
-      expect(tool_names).to include("bash")
+      expect(tool_names).to include("exec")
       expect(tool_names).not_to include("grep", "write")
     end
 
@@ -36,7 +36,7 @@ RSpec.describe Elelem::Toolbox do
       tools = subject.tools_for(mode)
 
       tool_names = tools.map { |t| t.dig(:function, :name) }
-      expect(tool_names).to include("grep", "list", "read", "patch", "write", "bash")
+      expect(tool_names).to include("grep", "list", "read", "patch", "write", "exec")
     end
 
     it "returns combined tools for build mode" do
@@ -45,7 +45,7 @@ RSpec.describe Elelem::Toolbox do
 
       tool_names = tools.map { |t| t.dig(:function, :name) }
       expect(tool_names).to include("grep", "read", "write", "patch")
-      expect(tool_names).not_to include("bash")
+      expect(tool_names).not_to include("exec")
     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,28 @@
 ## [Unreleased]
 
+## [0.6.0] - 2026-01-12
+
+### Added
+- `/env` slash command to capture environment variables for provider connections
+- `/shell` slash command
+- `/provider` and `/model` slash commands
+- Tab completion for commands
+- 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
 
 ### Added
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",
@@ -38,6 +39,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
@@ -1,7 +1,8 @@
 PATH
   remote: .
   specs:
-    elelem (0.5.0)
+    elelem (0.6.0)
+      cli-ui (~> 2.0)
       erb (~> 6.0)
       fileutils (~> 1.0)
       json (~> 2.0)
@@ -18,23 +19,24 @@ PATH
 GEM
   remote: https://rubygems.org/
   specs:
-    addressable (2.8.7)
-      public_suffix (>= 2.0.2, < 7.0)
+    addressable (2.8.8)
+      public_suffix (>= 2.0.2, < 8.0)
     base64 (0.3.0)
-    bigdecimal (3.2.2)
-    date (3.4.1)
+    bigdecimal (4.0.1)
+    cli-ui (2.7.0)
+    date (3.5.1)
     diff-lcs (1.6.2)
     erb (6.0.1)
     fileutils (1.8.0)
-    io-console (0.8.1)
-    irb (1.15.2)
+    io-console (0.8.2)
+    irb (1.16.0)
       pp (>= 0.6.0)
       rdoc (>= 4.0.0)
       reline (>= 0.4.2)
-    json (2.13.2)
-    json-schema (6.0.0)
+    json (2.18.0)
+    json-schema (6.1.0)
       addressable (~> 2.8)
-      bigdecimal (~> 3.1)
+      bigdecimal (>= 3.1, < 5)
     logger (1.7.0)
     net-hippie (1.4.0)
       base64 (~> 0.1)
@@ -42,46 +44,48 @@ GEM
       logger (~> 1.0)
       net-http (~> 0.6)
       openssl (~> 3.0)
-    net-http (0.6.0)
-      uri
+    net-http (0.9.1)
+      uri (>= 0.11.1)
     net-llm (0.5.0)
       json (~> 2.0)
       net-hippie (~> 1.0)
       uri (~> 1.0)
     open3 (0.2.1)
-    openssl (3.3.1)
+    openssl (3.3.2)
     pathname (0.4.0)
-    pp (0.6.2)
+    pp (0.6.3)
       prettyprint
     prettyprint (0.2.0)
-    psych (5.2.6)
+    psych (5.3.1)
       date
       stringio
-    public_suffix (6.0.2)
-    rake (13.3.0)
-    rdoc (6.14.2)
+    public_suffix (7.0.2)
+    rake (13.3.1)
+    rdoc (7.0.3)
       erb
       psych (>= 4.0.0)
-    reline (0.6.2)
+      tsort
+    reline (0.6.3)
       io-console (~> 0.5)
-    rspec (3.13.1)
+    rspec (3.13.2)
       rspec-core (~> 3.13.0)
       rspec-expectations (~> 3.13.0)
       rspec-mocks (~> 3.13.0)
-    rspec-core (3.13.5)
+    rspec-core (3.13.6)
       rspec-support (~> 3.13.0)
     rspec-expectations (3.13.5)
       diff-lcs (>= 1.2.0, < 2.0)
       rspec-support (~> 3.13.0)
-    rspec-mocks (3.13.5)
+    rspec-mocks (3.13.7)
       diff-lcs (>= 1.2.0, < 2.0)
       rspec-support (~> 3.13.0)
-    rspec-support (3.13.4)
+    rspec-support (3.13.6)
     set (1.1.2)
-    stringio (3.1.7)
-    thor (1.3.2)
-    timeout (0.4.3)
-    uri (1.0.3)
+    stringio (3.2.0)
+    thor (1.5.0)
+    timeout (0.6.0)
+    tsort (0.2.0)
+    uri (1.1.1)
 
 PLATFORMS
   ruby
README.md
@@ -141,7 +141,7 @@ seven tools, each represented by a JSON schema that the LLM can call.
 
 | Tool      | Purpose                              | Parameters                           |
 | ----      | -------                              | ----------                           |
-| `bash`    | Run shell commands                   | `cmd`, `args`, `env`, `cwd`, `stdin` |
+| `exec`    | Run shell commands                   | `cmd`, `args`, `env`, `cwd`, `stdin` |
 | `eval`    | Dynamically create new tools         | `code`                               |
 | `grep`    | Search Git‑tracked files             | `query`                              |
 | `list`    | List tracked files                   | `path` (optional)                    |