Commit 9b6d2e2

mo khan <mo@mokhan.ca>
2026-01-28 23:51:12
feat: add /mode to switch to a custom system prompt
1 parent 28e7962
lib/elelem/plugins/mode.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:mode) do |agent|
+  agent.commands.register("mode",
+    description: "Switch system prompt mode",
+    completions: -> { Elelem::SystemPrompt.available_modes }
+  ) do |args|
+    name = args&.strip
+    if name.nil? || name.empty?
+      agent.terminal.say Elelem::SystemPrompt.available_modes.join(" ")
+    else
+      agent.system_prompt.switch(name)
+      agent.terminal.say "mode: #{name}"
+    end
+  end
+end
lib/elelem/prompts/default.erb
@@ -0,0 +1,47 @@
+Terminal coding agent. Be concise. Verify your work.
+
+# Tools
+- read(path): file contents
+- write(path, content): create/overwrite file
+- execute(command): shell command
+- eval(ruby): execute Ruby code; use to create tools for repetitive tasks
+- task(prompt): delegate complex searches or multi-file analysis to a focused subagent
+
+# Editing
+Use execute(`patch -p1`) for multi-line changes: `echo "DIFF" | patch -p1`
+Use execute(`sed`) for single-line changes: `sed -i'' 's/old/new/' file`
+Use write for new files or full rewrites
+
+# Search
+Use execute(`rg`) for text search: `rg -n "pattern" .`
+Use execute(`fd`) for file discovery: `fd -e rb .`
+Use execute(`sg`) (ast-grep) for structural search: `sg -p 'def $NAME' -l ruby`
+
+# Task Management
+For complex tasks:
+1. State plan before acting
+2. Work through steps one at a time
+3. Summarize what was done
+
+# Long Tasks
+For complex multi-step work, write notes to .elelem/scratch.md
+
+# Policy
+- Explain before non-trivial commands
+- Verify changes (read file, run tests)
+- No interactive flags (-i, -p)
+- Use `man` when you need to understand how to execute a program
+
+# Environment
+pwd: <%= pwd %>
+platform: <%= platform %>
+date: <%= date %>
+self: <%= elelem_source %>
+<%= git_info %>
+
+<% if repo_map && !repo_map.empty? %>
+# Codebase
+```
+<%= repo_map %>```
+<% end %>
+<%= agents_md %>
lib/elelem/prompts/plan.erb
@@ -0,0 +1,25 @@
+Plan mode is active. You MUST NOT make any edits, run any non-readonly
+tools, or otherwise make any changes to the system.
+
+Allowed tools: read, glob, grep, task
+Blocked tools: write, edit, execute
+
+# Your Task
+1. Explore the codebase to understand the request
+2. Design an implementation approach
+3. Write your plan to .elelem/plan.md
+4. Summarize the plan for user approval
+
+# Environment
+pwd: <%= pwd %>
+platform: <%= platform %>
+date: <%= date %>
+self: <%= elelem_source %>
+<%= git_info %>
+
+<% if repo_map && !repo_map.empty? %>
+# Codebase
+```
+<%= repo_map %>```
+<% end %>
+<%= agents_md %>
lib/elelem/commands.rb
@@ -6,8 +6,16 @@ module Elelem
       @registry = {}
     end
 
-    def register(name, description: "", &handler)
-      @registry[name] = { description: description, handler: handler }
+    def register(name, description: "", completions: nil, &handler)
+      @registry[name] = { description: description, completions: completions, handler: handler }
+    end
+
+    def completions_for(name, partial = "")
+      cmd = @registry[name]
+      return [] unless cmd && cmd[:completions]
+
+      options = cmd[:completions].respond_to?(:call) ? cmd[:completions].call : cmd[:completions]
+      options.select { |o| o.start_with?(partial) }
     end
 
     def run(name, args = nil)
lib/elelem/system_prompt.rb
@@ -2,60 +2,52 @@
 
 module Elelem
   class SystemPrompt
-    TEMPLATE = <<~ERB
-      Terminal coding agent. Be concise. Verify your work.
-
-      # Tools
-      - read(path): file contents
-      - write(path, content): create/overwrite file
-      - execute(command): shell command
-      - eval(ruby): execute Ruby code; use to create tools for repetitive tasks
-      - task(prompt): delegate complex searches or multi-file analysis to a focused subagent
-
-      # Editing
-      Use execute(`patch -p1`) for multi-line changes: `echo "DIFF" | patch -p1`
-      Use execute(`sed`) for single-line changes: `sed -i'' 's/old/new/' file`
-      Use write for new files or full rewrites
-
-      # Search
-      Use execute(`rg`) for text search: `rg -n "pattern" .`
-      Use execute(`fd`) for file discovery: `fd -e rb .`
-      Use execute(`sg`) (ast-grep) for structural search: `sg -p 'def $NAME' -l ruby`
-
-      # Task Management
-      For complex tasks:
-      1. State plan before acting
-      2. Work through steps one at a time
-      3. Summarize what was done
-
-      # Long Tasks
-      For complex multi-step work, write notes to .elelem/scratch.md
-
-      # Policy
-      - Explain before non-trivial commands
-      - Verify changes (read file, run tests)
-      - No interactive flags (-i, -p)
-      - Use `man` when you need to understand how to execute a program
-
-      # Environment
-      pwd: <%= pwd %>
-      platform: <%= platform %>
-      date: <%= date %>
-      self: <%= elelem_source %>
-      <%= git_info %>
-
-      <% if repo_map && !repo_map.empty? %>
-      # Codebase
-      ```
-      <%= repo_map %>```
-      <% end %>
-      <%= agents_md %>
-    ERB
+    LOAD_PATHS = [
+      File.expand_path("prompts", __dir__),
+      File.expand_path("~/.elelem/prompts"),
+      ".elelem/prompts"
+    ].freeze
+
+    class << self
+      def templates
+        @templates ||= load_templates
+      end
+
+      def available_modes
+        templates.keys.sort
+      end
+
+      def get(name)
+        templates[name.to_s] || templates["default"]
+      end
+
+      def reload!
+        @templates = nil
+      end
+
+      private
+
+      def load_templates
+        result = {}
+        LOAD_PATHS.each do |dir|
+          next unless File.directory?(dir)
+
+          Dir[File.join(dir, "*.erb")].each do |path|
+            result[File.basename(path, ".erb")] = File.read(path)
+          end
+        end
+        result
+      end
+    end
 
     attr_accessor :template
 
     def initialize(template = nil)
-      @template = template || TEMPLATE
+      @template = template || self.class.get("default")
+    end
+
+    def switch(name)
+      @template = self.class.get(name)
     end
 
     def render
lib/elelem/terminal.rb
@@ -90,11 +90,29 @@ module Elelem
 
     def complete(target, preposing)
       line = "#{preposing}#{target}"
-      return @commands.select { |c| c.start_with?(line) } if line.start_with?("/") && !preposing.include?(" ")
+
+      if line.start_with?("/") && !preposing.include?(" ")
+        return command_names.select { |c| c.start_with?(line) }
+      end
+
+      if preposing.start_with?("/") && preposing.include?(" ")
+        cmd_name = preposing.delete_prefix("/").split(" ", 2).first
+        return complete_command_args(cmd_name, target)
+      end
 
       complete_files(target)
     end
 
+    def command_names
+      @commands.respond_to?(:names) ? @commands.names : @commands
+    end
+
+    def complete_command_args(cmd_name, partial)
+      return [] unless @commands.respond_to?(:completions_for)
+
+      @commands.completions_for(cmd_name, partial)
+    end
+
     def complete_files(target)
       result = Elelem.sh("bash", args: ["-c", "compgen -f #{target}"])
       result[:content].lines.map(&:strip).first(20)
lib/elelem.rb
@@ -50,7 +50,7 @@ module Elelem
   def self.start(client, toolbox: Toolbox.new)
     agent = Agent.new(client, toolbox: toolbox)
     Plugins.setup!(agent)
-    agent.terminal = Terminal.new(commands: agent.commands.names)
+    agent.terminal = Terminal.new(commands: agent.commands)
     agent.repl
   end
 
spec/elelem/system_prompt_spec.rb
@@ -0,0 +1,73 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::SystemPrompt do
+  before { described_class.reload! }
+
+  describe ".available_modes" do
+    it "returns sorted list of template names" do
+      expect(described_class.available_modes).to include("default", "plan")
+    end
+
+    it "returns names in alphabetical order" do
+      modes = described_class.available_modes
+      expect(modes).to eq(modes.sort)
+    end
+  end
+
+  describe ".get" do
+    it "returns template content for known name" do
+      template = described_class.get("default")
+      expect(template).to include("Terminal coding agent")
+    end
+
+    it "returns plan template" do
+      template = described_class.get("plan")
+      expect(template).to include("Plan mode is active")
+    end
+
+    it "falls back to default for unknown name" do
+      template = described_class.get("nonexistent")
+      expect(template).to eq(described_class.get("default"))
+    end
+  end
+
+  describe "#switch" do
+    it "changes the template" do
+      prompt = described_class.new
+      expect(prompt.template).to include("Terminal coding agent")
+
+      prompt.switch("plan")
+      expect(prompt.template).to include("Plan mode is active")
+    end
+  end
+
+  describe "override behavior" do
+    let(:tmpdir) { Dir.mktmpdir }
+
+    around do |example|
+      original_dir = Dir.pwd
+      Dir.chdir(tmpdir)
+      dir = ".elelem/prompts"
+      FileUtils.mkdir_p(dir)
+      File.write("#{dir}/custom.erb", "Custom template")
+      described_class.reload!
+      example.run
+      Dir.chdir(original_dir)
+      FileUtils.rm_rf(tmpdir)
+      described_class.reload!
+    end
+
+    it "loads project-level templates" do
+      expect(described_class.available_modes).to include("custom")
+      expect(described_class.get("custom")).to eq("Custom template")
+    end
+
+    it "project templates override built-in" do
+      dir = ".elelem/prompts"
+      File.write("#{dir}/default.erb", "Overridden default")
+      described_class.reload!
+
+      expect(described_class.get("default")).to eq("Overridden default")
+    end
+  end
+end