Comparing changes

v0.1.2 v0.1.3
20 commits 26 files changed

Commits

7891618 chore: bump version mo khan 2025-08-15 00:33:39
3c5caa4 fix: correct mcp server initialization mo khan 2025-08-14 22:52:47
538836f feat: add a frame around the tool call mo khan 2025-08-14 22:01:58
3b148c6 style: fix linter errors mo khan 2025-08-14 21:09:16
58099dd fix: version subcommand mo khan 2025-08-14 21:07:50
3482afa feat: switch to bash mo khan 2025-08-14 18:38:18
fe04748 refactor: move tools into toolbox mo khan 2025-08-14 18:35:35
c34aa3e refactor: delegate to tui mo khan 2025-08-14 18:28:29
exe/elelem
@@ -3,9 +3,6 @@
 
 require "elelem"
 
-Reline.input = $stdin
-Reline.output = $stdout
-
 Signal.trap("INT") do
   exit(1)
 end
lib/elelem/states/working/error.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Elelem
+  module States
+    module Working
+      class Error < State
+        def initialize(agent, error_message)
+          super(agent, "X", :red)
+          @error_message = error_message
+        end
+
+        def process(_message)
+          agent.tui.say("\nTool execution failed: #{@error_message}", colour: :red)
+          Waiting.new(agent)
+        end
+      end
+    end
+  end
+end
lib/elelem/states/working/executing.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Elelem
+  module States
+    module Working
+      class Executing < State
+        def process(message)
+          if message["tool_calls"]&.any?
+            message["tool_calls"].each do |tool_call|
+              agent.conversation.add(role: :tool, content: agent.execute(tool_call))
+            end
+          end
+
+          Waiting.new(agent)
+        end
+      end
+    end
+  end
+end
lib/elelem/states/working/state.rb
@@ -0,0 +1,26 @@
+# frozen_string_literal: true
+
+module Elelem
+  module States
+    module Working
+      class State
+        attr_reader :agent
+
+        def initialize(agent, icon, colour)
+          @agent = agent
+
+          agent.logger.debug("#{display_name}...")
+          agent.tui.show_progress("#{display_name}...", icon, colour: colour)
+        end
+
+        def run(message)
+          process(message)
+        end
+
+        def display_name
+          self.class.name.split("::").last
+        end
+      end
+    end
+  end
+end
lib/elelem/states/working/talking.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Elelem
+  module States
+    module Working
+      class Talking < State
+        def process(message)
+          if message["content"] && !message["content"]&.empty?
+            agent.conversation.add(role: message["role"], content: message["content"])
+            agent.tui.say(message["content"], colour: :default, newline: false)
+            self
+          else
+            Waiting.new(agent).process(message)
+          end
+        end
+      end
+    end
+  end
+end
lib/elelem/states/working/thinking.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Elelem
+  module States
+    module Working
+      class Thinking < State
+        def process(message)
+          if message["thinking"] && !message["thinking"]&.empty?
+            agent.tui.say(message["thinking"], colour: :gray, newline: false)
+            self
+          else
+            Waiting.new(agent).process(message)
+          end
+        end
+      end
+    end
+  end
+end
lib/elelem/states/working/waiting.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Elelem
+  module States
+    module Working
+      class Waiting < State
+        def initialize(agent)
+          super(agent, ".", :cyan)
+        end
+
+        def process(message)
+          state_for(message)&.process(message)
+        end
+
+        private
+
+        def state_for(message)
+          if message["thinking"] && !message["thinking"].empty?
+            Thinking.new(agent, "*", :yellow)
+          elsif message["tool_calls"]&.any?
+            Executing.new(agent, ">", :magenta)
+          elsif message["content"] && !message["content"].empty?
+            Talking.new(agent, "~", :white)
+          end
+        end
+      end
+    end
+  end
+end
lib/elelem/states/idle.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Elelem
+  module States
+    class Idle
+      def run(agent)
+        agent.logger.debug("Idling...")
+        agent.tui.say("#{Dir.pwd} (#{agent.model}) [#{git_branch}]", colour: :magenta, newline: true)
+        input = agent.tui.prompt("モ ")
+        agent.quit if input.nil? || input.empty? || input == "exit" || input == "quit"
+
+        agent.conversation.add(role: :user, content: input)
+        agent.transition_to(Working)
+      end
+
+      private
+
+      def git_branch
+        `git branch --no-color --show-current --no-abbrev`.strip
+      end
+    end
+  end
+end
lib/elelem/states/working.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+module Elelem
+  module States
+    module Working
+      class << self
+        def run(agent)
+          done = false
+          state = Waiting.new(agent)
+
+          loop do
+            agent.api.chat(agent.conversation.history) do |chunk|
+              response = JSON.parse(chunk)
+              message = normalize(response["message"] || {})
+              done = response["done"]
+
+              agent.logger.debug("#{state.display_name}: #{message}")
+              state = state.run(message)
+            end
+
+            break if state.nil?
+            break if done && agent.conversation.history.last[:role] != :tool
+          end
+
+          agent.transition_to(States::Idle.new)
+        end
+
+        def normalize(message)
+          message.reject { |_key, value| value.empty? }
+        end
+      end
+    end
+  end
+end
lib/elelem/toolbox/bash.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Toolbox
+    class Bash < ::Elelem::Tool
+      attr_reader :tui
+
+      def initialize(configuration)
+        @tui = configuration.tui
+        super("bash", "Run commands in /bin/bash -c. Full access to filesystem, network, processes, and all Unix tools.", {
+          type: "object",
+          properties: {
+            command: { type: "string" }
+          },
+          required: ["command"]
+        })
+      end
+
+      def call(args)
+        command = args["command"]
+        output_buffer = []
+
+        Open3.popen3("/bin/bash", "-c", command) do |stdin, stdout, stderr, wait_thread|
+          stdin.close
+          streams = [stdout, stderr]
+
+          until streams.empty?
+            ready = IO.select(streams, nil, nil, 0.1)
+
+            if ready
+              ready[0].each do |io|
+                data = io.read_nonblock(4096)
+                output_buffer << data
+
+                if io == stderr
+                  tui.say(data, colour: :red, newline: false)
+                else
+                  tui.say(data, newline: false)
+                end
+              rescue IO::WaitReadable
+                next
+              rescue EOFError
+                streams.delete(io)
+              end
+            elsif !wait_thread.alive?
+              break
+            end
+          end
+
+          wait_thread.value
+        end
+
+        output_buffer.join
+      end
+    end
+  end
+end
lib/elelem/toolbox/mcp.rb
@@ -0,0 +1,37 @@
+# frozen_string_literal: true
+
+module Elelem
+  module Toolbox
+    class MCP < ::Elelem::Tool
+      attr_reader :client, :tui
+
+      def initialize(client, tui, tool)
+        @client = client
+        @tui = tui
+        super(tool["name"], tool["description"], tool["inputSchema"] || {})
+      end
+
+      def call(args)
+        unless client.connected?
+          tui.say("MCP connection lost", colour: :red)
+          return ""
+        end
+
+        result = client.call(name, args)
+        tui.say(JSON.pretty_generate(result), newline: true)
+
+        if result.nil? || result.empty?
+          tui.say("Tool call failed: no response from MCP server", colour: :red)
+          return result
+        end
+
+        if result["error"]
+          tui.say(result["error"], colour: :red)
+          return result
+        end
+
+        result.dig("content", 0, "text") || result.to_s
+      end
+    end
+  end
+end
lib/elelem/agent.rb
@@ -2,15 +2,19 @@
 
 module Elelem
   class Agent
-    attr_reader :api, :conversation, :logger, :model
+    attr_reader :api, :conversation, :logger, :model, :tui
 
     def initialize(configuration)
       @api = configuration.api
+      @tui = configuration.tui
       @configuration = configuration
       @model = configuration.model
       @conversation = configuration.conversation
       @logger = configuration.logger
-      transition_to(Idle.new)
+
+      at_exit { cleanup }
+
+      transition_to(States::Idle.new)
     end
 
     def repl
@@ -24,36 +28,22 @@ module Elelem
       @current_state = next_state
     end
 
-    def prompt(message)
-      configuration.tui.prompt(message)
-    end
-
-    def say(message, colour: :default, newline: false)
-      configuration.tui.say(message, colour: colour, newline: newline)
-    end
-
     def execute(tool_call)
       logger.debug("Execute: #{tool_call}")
       configuration.tools.execute(tool_call)
     end
 
-    def show_progress(message, prefix = "[.]", colour: :gray)
-      configuration.tui.show_progress(message, prefix, colour: colour)
-    end
-
-    def clear_line
-      configuration.tui.clear_line
-    end
-
-    def complete_progress(message = "Completed")
-      configuration.tui.complete_progress(message)
-    end
-
     def quit
       logger.debug("Exiting...")
+      cleanup
       exit
     end
 
+    def cleanup
+      logger.debug("Cleaning up agent...")
+      configuration.cleanup
+    end
+
     private
 
     attr_reader :configuration, :current_state
lib/elelem/application.rb
@@ -45,9 +45,9 @@ module Elelem
       end
     end
 
-    desc "version", "spandx version"
+    desc "version", "The version of this CLI"
     def version
-      puts "v#{Spandx::VERSION}"
+      say "v#{Elelem::VERSION}"
     end
     map %w[--version -v] => :version
   end
lib/elelem/configuration.rb
@@ -37,11 +37,22 @@ module Elelem
     end
 
     def conversation
-      @conversation ||= Conversation.new
+      @conversation ||= Conversation.new.tap do |conversation|
+        resources = mcp_clients.map do |client|
+          client.resources.map do |resource|
+            resource["uri"]
+          end
+        end.flatten
+        conversation.add(role: :tool, content: resources)
+      end
     end
 
     def tools
-      @tools ||= Tools.new(self, [BashTool.new(self)] + mcp_tools)
+      @tools ||= Tools.new(self, [Toolbox::Bash.new(self)] + mcp_tools)
+    end
+
+    def cleanup
+      @mcp_clients&.each(&:shutdown)
     end
 
     private
@@ -50,23 +61,23 @@ module Elelem
       host.match?(/\A(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?\z/) ? "http" : "https"
     end
 
-    def mcp_tools(clients = [serena_client])
-      return [] if ENV["SMALL"]
-
-      @mcp_tools ||= clients.map { |client| client.tools.map { |tool| MCPTool.new(client, tui, tool) } }.flatten
+    def mcp_tools
+      @mcp_tools ||= mcp_clients.map do |client|
+        client.tools.map do |tool|
+          Toolbox::MCP.new(client, tui, tool)
+        end
+      end.flatten
     end
 
-    def serena_client
-      MCPClient.new(self, [
-        "uvx",
-        "--from",
-        "git+https://github.com/oraios/serena",
-        "serena",
-        "start-mcp-server",
-        "--transport", "stdio",
-        "--context", "ide-assistant",
-        "--project", Dir.pwd
-      ])
+    def mcp_clients
+      @mcp_clients ||= begin
+        config = Pathname.pwd.join(".mcp.json")
+        return [] unless config.exist?
+
+        JSON.parse(config.read).map do |_key, value|
+          MCPClient.new(self, [value["command"]] + value["args"])
+        end
+      end
     end
   end
 end
lib/elelem/conversation.rb
@@ -21,7 +21,7 @@ module Elelem
       if @items.last && @items.last[:role] == role
         @items.last[:content] += content
       else
-        @items.push({ role: role, content: content })
+        @items.push({ role: role, content: normalize(content) })
       end
     end
 
@@ -30,5 +30,13 @@ module Elelem
     def system_prompt
       ERB.new(Pathname.new(__dir__).join("system_prompt.erb").read).result(binding)
     end
+
+    def normalize(content)
+      if content.is_a?(Array)
+        content.join(", ")
+      else
+        content.to_s
+      end
+    end
   end
 end
lib/elelem/mcp_client.rb
@@ -2,7 +2,7 @@
 
 module Elelem
   class MCPClient
-    attr_reader :tools
+    attr_reader :tools, :resources
 
     def initialize(configuration, command = [])
       @configuration = configuration
@@ -12,7 +12,7 @@ module Elelem
       send_request(
         method: "initialize",
         params: {
-          protocolVersion: "2024-11-05",
+          protocolVersion: "2025-06-08",
           capabilities: {
             tools: {}
           },
@@ -23,11 +23,12 @@ module Elelem
         }
       )
 
-      # 2. Send initialized notification (required by MCP protocol)
+      # 2. Send initialized notification (optional for some MCP servers)
       send_notification(method: "notifications/initialized")
 
       # 3. Now we can request tools
       @tools = send_request(method: "tools/list")&.dig("tools") || []
+      @resources = send_request(method: "resources/list")&.dig("resources") || []
     end
 
     def connected?
@@ -53,6 +54,33 @@ module Elelem
       )
     end
 
+    def shutdown
+      return unless connected?
+
+      configuration.logger.debug("Shutting down MCP client")
+
+      [@stdin, @stdout, @stderr].each do |stream|
+        stream&.close unless stream&.closed?
+      end
+
+      return unless @worker&.alive?
+
+      begin
+        Process.kill("TERM", @worker.pid)
+        # Give it 2 seconds to terminate gracefully
+        Timeout.timeout(2) { @worker.value }
+      rescue Timeout::Error
+        # Force kill if it doesn't respond
+        begin
+          Process.kill("KILL", @worker.pid)
+        rescue StandardError
+          nil
+        end
+      rescue Errno::ESRCH
+        # Process already dead
+      end
+    end
+
     private
 
     attr_reader :stdin, :stdout, :stderr, :worker, :configuration
@@ -86,6 +114,8 @@ module Elelem
     end
 
     def send_notification(method:, params: {})
+      return unless connected?
+
       notification = {
         jsonrpc: "2.0",
         method: method
@@ -94,6 +124,13 @@ module Elelem
       configuration.logger.debug("Sending notification: #{JSON.pretty_generate(notification)}")
       @stdin.puts(JSON.generate(notification))
       @stdin.flush
+
+      response_line = @stdout.gets&.strip
+      return {} if response_line.nil? || response_line.empty?
+
+      response = JSON.parse(response_line)
+      configuration.logger.debug(JSON.pretty_generate(response))
+      response
     end
   end
 end
lib/elelem/state.rb
@@ -1,162 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  class Idle
-    def run(agent)
-      agent.logger.debug("Idling...")
-      agent.say("#{Dir.pwd} (#{agent.model}) [#{git_branch}]", colour: :magenta, newline: true)
-      input = agent.prompt("モ ")
-      agent.quit if input.nil? || input.empty? || input == "exit" || input == "quit"
-
-      agent.conversation.add(role: :user, content: input)
-      agent.transition_to(Working.new)
-    end
-
-    private
-
-    def git_branch
-      `git branch --no-color --show-current --no-abbrev`.strip
-    end
-  end
-
-  class Working
-    class State
-      attr_reader :agent
-
-      def initialize(agent)
-        @agent = agent
-      end
-
-      def display_name
-        self.class.name.split("::").last
-      end
-    end
-
-    class Waiting < State
-      def process(message)
-        state_for(message)&.process(message)
-      end
-
-      private
-
-      def state_for(message)
-        if message["thinking"] && !message["thinking"].empty?
-          Thinking.new(agent)
-        elsif message["tool_calls"]&.any?
-          Executing.new(agent)
-        elsif message["content"] && !message["content"].empty?
-          Talking.new(agent)
-        end
-      end
-    end
-
-    class Thinking < State
-      def initialize(agent)
-        super(agent)
-        @progress_shown = false
-      end
-
-      def process(message)
-        if message["thinking"] && !message["thinking"]&.empty?
-          unless @progress_shown
-            agent.show_progress("Thinking...", "[*]", colour: :yellow)
-            agent.say("\n\n", newline: false)
-            @progress_shown = true
-          end
-          agent.say(message["thinking"], colour: :gray, newline: false)
-          self
-        else
-          agent.say("\n\n", newline: false)
-          Waiting.new(agent).process(message)
-        end
-      end
-    end
-
-    class Executing < State
-      def process(message)
-        if message["tool_calls"]&.any?
-          message["tool_calls"].each do |tool_call|
-            tool_name = tool_call.dig("function", "name") || "unknown"
-            agent.show_progress(tool_name, "[>]", colour: :magenta)
-            agent.say("\n\n", newline: false)
-
-            output = agent.execute(tool_call)
-            agent.conversation.add(role: :tool, content: output)
-
-            agent.say("\n", newline: false)
-            agent.complete_progress("#{tool_name} completed")
-          end
-        end
-
-        Waiting.new(agent)
-      end
-    end
-
-    class Error < State
-      def initialize(agent, error_message)
-        super(agent)
-        @error_message = error_message
-      end
-
-      def process(_message)
-        agent.say("\nTool execution failed: #{@error_message}", colour: :red)
-        agent.say("Returning to idle state.\n\n", colour: :yellow)
-        Waiting.new(agent)
-      end
-    end
-
-    class Talking < State
-      def initialize(agent)
-        super(agent)
-        @progress_shown = false
-      end
-
-      def process(message)
-        if message["content"] && !message["content"]&.empty?
-          unless @progress_shown
-            agent.show_progress("Responding...", "[~]", colour: :white)
-            agent.say("\n", newline: false)
-            @progress_shown = true
-          end
-          agent.conversation.add(role: message["role"], content: message["content"])
-          agent.say(message["content"], colour: :default, newline: false)
-          self
-        else
-          agent.say("\n\n", newline: false)
-          Waiting.new(agent).process(message)
-        end
-      end
-    end
-
-    def run(agent)
-      agent.logger.debug("Working...")
-      agent.show_progress("Processing...", "[.]", colour: :cyan)
-      agent.say("\n\n", newline: false)
-
-      state = Waiting.new(agent)
-      done = false
-
-      loop do
-        agent.api.chat(agent.conversation.history) do |chunk|
-          response = JSON.parse(chunk)
-          message = normalize(response["message"] || {})
-          done = response["done"]
-
-          agent.logger.debug("#{state.display_name}: #{message}")
-          state = state.process(message)
-        end
-
-        break if state.nil?
-        break if done && agent.conversation.history.last[:role] != :tool
-      end
-
-      agent.transition_to(Idle.new)
-    end
-
-    private
-
-    def normalize(message)
-      message.reject { |_key, value| value.empty? }
-    end
-  end
-end
lib/elelem/system_prompt.erb
@@ -1,7 +1,7 @@
-**Del — AI** — Direct/no fluff; prose unless bullets; concise/simple, thorough/complex; critical>agree; honest always; AI≠human. TDD→SOLID→SRP/encapsulation/composition>inheritance; patterns only if needed; self-doc names; simple>complex; no cleverness. Unix: small tools, 1 job, pipe; prefer built-ins; cite man(1); note POSIX≠GNU; stdin/stdout streams.
+**Shell Master** — bash>code; compose>write; pipe everything; /proc/sys native; automate fast; streams/transforms; POSIX+GNU; man(1) first; no cleverness.
 
 Time: `<%= Time.now.strftime("%Y-%m-%d %H:%M:%S") %>`
 Project Directory: `<%= Dir.pwd %>`
 System Info: `<%= `uname -a`.strip %>`
 
-Del is now being connected with a person.
+Ready to hack.
lib/elelem/tool.rb
@@ -29,88 +29,4 @@ module Elelem
       }
     end
   end
-
-  class BashTool < Tool
-    attr_reader :tui
-
-    def initialize(configuration)
-      @tui = configuration.tui
-      super("bash", "Execute a shell command.", {
-        type: "object",
-        properties: {
-          command: { type: "string" }
-        },
-        required: ["command"]
-      })
-    end
-
-    def call(args)
-      command = args["command"]
-      output_buffer = []
-
-      Open3.popen3("/bin/sh", "-c", command) do |stdin, stdout, stderr, wait_thread|
-        stdin.close
-        streams = [stdout, stderr]
-
-        until streams.empty?
-          ready = IO.select(streams, nil, nil, 0.1)
-
-          if ready
-            ready[0].each do |io|
-              data = io.read_nonblock(4096)
-              output_buffer << data
-
-              if io == stderr
-                tui.say(data, colour: :red, newline: false)
-              else
-                tui.say(data, newline: false)
-              end
-            rescue IO::WaitReadable
-              next
-            rescue EOFError
-              streams.delete(io)
-            end
-          elsif !wait_thread.alive?
-            break
-          end
-        end
-
-        wait_thread.value
-      end
-
-      output_buffer.join
-    end
-  end
-
-  class MCPTool < Tool
-    attr_reader :client, :tui
-
-    def initialize(client, tui, tool)
-      @client = client
-      @tui = tui
-      super(tool["name"], tool["description"], tool["inputSchema"] || {})
-    end
-
-    def call(args)
-      unless client.connected?
-        tui.say("MCP connection lost", colour: :red)
-        return ""
-      end
-
-      result = client.call(name, args)
-      tui.say(result)
-
-      if result.nil? || result.empty?
-        tui.say("Tool call failed: no response from MCP server", colour: :red)
-        return result
-      end
-
-      if result["error"]
-        tui.say(result["error"], colour: :red)
-        return result
-      end
-
-      result.dig("content", 0, "text") || result.to_s
-    end
-  end
 end
lib/elelem/tools.rb
@@ -19,7 +19,9 @@ module Elelem
       return "Invalid function name: #{name}" if tool.nil?
       return "Invalid function arguments: #{args}" unless tool.valid?(args)
 
-      tool.call(args)
+      CLI::UI::Frame.open(name) do
+        tool.call(args)
+      end
     end
 
     def to_h
lib/elelem/tui.rb
@@ -14,41 +14,30 @@ module Elelem
     end
 
     def say(message, colour: :default, newline: false)
-      formatted_message = colourize(message, colour: colour)
       if newline
-        stdout.puts(formatted_message)
+        stdout.puts(colourize(message, colour: colour))
       else
-        stdout.print(formatted_message)
+        stdout.print(colourize(message, colour: colour))
       end
       stdout.flush
     end
 
-    def show_progress(message, prefix = "[.]", colour: :gray)
-      timestamp = current_time_string
-      formatted_message = colourize("#{prefix} #{timestamp} #{message}", colour: colour)
-      stdout.print(formatted_message)
-      stdout.flush
+    def show_progress(message, icon = ".", colour: :gray)
+      timestamp = Time.now.strftime("%H:%M:%S")
+      say("\n[#{icon}] #{timestamp} #{message}", colour: colour, newline: true)
     end
 
     def clear_line
-      stdout.print("\r#{" " * 80}\r")
-      stdout.flush
+      say("\r#{" " * 80}\r", newline: false)
     end
 
     def complete_progress(message = "Completed")
       clear_line
-      timestamp = current_time_string
-      formatted_message = colourize("[✓] #{timestamp} #{message}", colour: :green)
-      stdout.puts(formatted_message)
-      stdout.flush
+      show_progress(message, "✓", colour: :green)
     end
 
     private
 
-    def current_time_string
-      Time.now.strftime("%H:%M:%S")
-    end
-
     def colourize(text, colour: :default)
       case colour
       when :black
lib/elelem/version.rb
@@ -1,5 +1,5 @@
 # frozen_string_literal: true
 
 module Elelem
-  VERSION = "0.1.2"
+  VERSION = "0.1.3"
 end
lib/elelem.rb
@@ -1,5 +1,6 @@
 # frozen_string_literal: true
 
+require "cli/ui"
 require "erb"
 require "json"
 require "json-schema"
@@ -8,6 +9,7 @@ require "net/http"
 require "open3"
 require "reline"
 require "thor"
+require "timeout"
 require "uri"
 
 require_relative "elelem/agent"
@@ -16,12 +18,25 @@ require_relative "elelem/application"
 require_relative "elelem/configuration"
 require_relative "elelem/conversation"
 require_relative "elelem/mcp_client"
-require_relative "elelem/state"
+require_relative "elelem/states/idle"
+require_relative "elelem/states/working"
+require_relative "elelem/states/working/state"
+require_relative "elelem/states/working/error"
+require_relative "elelem/states/working/executing"
+require_relative "elelem/states/working/talking"
+require_relative "elelem/states/working/thinking"
+require_relative "elelem/states/working/waiting"
 require_relative "elelem/tool"
+require_relative "elelem/toolbox/bash"
+require_relative "elelem/toolbox/mcp"
 require_relative "elelem/tools"
 require_relative "elelem/tui"
 require_relative "elelem/version"
 
+CLI::UI::StdoutRouter.enable
+Reline.input = $stdin
+Reline.output = $stdout
+
 module Elelem
   class Error < StandardError; end
 end
.gitignore
@@ -14,6 +14,7 @@ mkmf.log
 target/
 *.log
 *.gem
+.mcp.json
 
 # rspec failure tracking
 .rspec_status
elelem.gemspec
@@ -40,9 +40,18 @@ Gem::Specification.new do |spec|
     "lib/elelem/configuration.rb",
     "lib/elelem/conversation.rb",
     "lib/elelem/mcp_client.rb",
-    "lib/elelem/state.rb",
+    "lib/elelem/states/idle.rb",
+    "lib/elelem/states/working.rb",
+    "lib/elelem/states/working/error.rb",
+    "lib/elelem/states/working/executing.rb",
+    "lib/elelem/states/working/state.rb",
+    "lib/elelem/states/working/talking.rb",
+    "lib/elelem/states/working/thinking.rb",
+    "lib/elelem/states/working/waiting.rb",
     "lib/elelem/system_prompt.erb",
     "lib/elelem/tool.rb",
+    "lib/elelem/toolbox/bash.rb",
+    "lib/elelem/toolbox/mcp.rb",
     "lib/elelem/tools.rb",
     "lib/elelem/tui.rb",
     "lib/elelem/version.rb",
@@ -52,6 +61,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"
   spec.add_dependency "erb"
   spec.add_dependency "json"
   spec.add_dependency "json-schema"
@@ -60,5 +70,6 @@ Gem::Specification.new do |spec|
   spec.add_dependency "open3"
   spec.add_dependency "reline"
   spec.add_dependency "thor"
+  spec.add_dependency "timeout"
   spec.add_dependency "uri"
 end
Gemfile.lock
@@ -1,7 +1,8 @@
 PATH
   remote: .
   specs:
-    elelem (0.1.2)
+    elelem (0.1.3)
+      cli-ui
       erb
       json
       json-schema
@@ -10,6 +11,7 @@ PATH
       open3
       reline
       thor
+      timeout
       uri
 
 GEM
@@ -19,6 +21,7 @@ GEM
       public_suffix (>= 2.0.2, < 7.0)
     ast (2.4.3)
     bigdecimal (3.2.2)
+    cli-ui (2.4.0)
     date (3.4.1)
     diff-lcs (1.6.2)
     erb (5.0.2)
@@ -88,6 +91,7 @@ GEM
     ruby-progressbar (1.13.0)
     stringio (3.1.7)
     thor (1.3.2)
+    timeout (0.4.3)
     unicode-display_width (3.1.4)
       unicode-emoji (~> 4.0, >= 4.0.4)
     unicode-emoji (4.0.4)