diff --git a/README.md b/README.md
index 0774826b..fff8d5fc 100644
--- a/README.md
+++ b/README.md
@@ -263,7 +263,10 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
 
       -- Provider-specific options
       provider_opts = {
-        external_terminal_cmd = nil, -- Command template for external terminal provider (e.g., "alacritty -e %s")
+        -- Command for external terminal provider. Can be:
+        -- 1. String with %s placeholder: "alacritty -e %s"
+        -- 2. Function returning command: function(cmd, env) return "alacritty -e " .. cmd end
+        external_terminal_cmd = nil,
       },
     },
 
@@ -452,13 +455,34 @@ For complete configuration options, see:
 Run Claude Code in a separate terminal application outside of Neovim:
 
 ```lua
+-- Using a string template (simple)
 {
   "coder/claudecode.nvim",
   opts = {
     terminal = {
       provider = "external",
       provider_opts = {
-        external_terminal_cmd = "alacritty -e %s", -- Replace with your preferred terminal program. %s is replaced with claude command
+        external_terminal_cmd = "alacritty -e %s", -- %s is replaced with claude command
+      },
+    },
+  },
+}
+
+-- Using a function for dynamic command generation (advanced)
+{
+  "coder/claudecode.nvim",
+  opts = {
+    terminal = {
+      provider = "external",
+      provider_opts = {
+        external_terminal_cmd = function(cmd, env)
+          -- You can build complex commands based on environment or conditions
+          if vim.fn.has("mac") == 1 then
+            return { "osascript", "-e", string.format('tell app "Terminal" to do script "%s"', cmd) }
+          else
+            return "alacritty -e " .. cmd
+          end
+        end,
       },
     },
   },
diff --git a/lua/claudecode/config.lua b/lua/claudecode/config.lua
index 56767813..3886d2b3 100644
--- a/lua/claudecode/config.lua
+++ b/lua/claudecode/config.lua
@@ -61,11 +61,13 @@ function M.validate(config)
 
     -- Validate external_terminal_cmd in provider_opts
     if config.terminal.provider_opts.external_terminal_cmd then
+      local cmd_type = type(config.terminal.provider_opts.external_terminal_cmd)
       assert(
-        type(config.terminal.provider_opts.external_terminal_cmd) == "string",
-        "terminal.provider_opts.external_terminal_cmd must be a string"
+        cmd_type == "string" or cmd_type == "function",
+        "terminal.provider_opts.external_terminal_cmd must be a string or function"
       )
-      if config.terminal.provider_opts.external_terminal_cmd ~= "" then
+      -- Only validate %s placeholder for strings
+      if cmd_type == "string" and config.terminal.provider_opts.external_terminal_cmd ~= "" then
         assert(
           config.terminal.provider_opts.external_terminal_cmd:find("%%s"),
           "terminal.provider_opts.external_terminal_cmd must contain '%s' placeholder for the Claude command"
@@ -108,7 +110,9 @@ function M.validate(config)
   assert(type(config.diff_opts.show_diff_stats) == "boolean", "diff_opts.show_diff_stats must be a boolean")
   assert(type(config.diff_opts.vertical_split) == "boolean", "diff_opts.vertical_split must be a boolean")
   assert(type(config.diff_opts.open_in_current_tab) == "boolean", "diff_opts.open_in_current_tab must be a boolean")
-  assert(type(config.diff_opts.keep_terminal_focus) == "boolean", "diff_opts.keep_terminal_focus must be a boolean")
+  if config.diff_opts.keep_terminal_focus ~= nil then
+    assert(type(config.diff_opts.keep_terminal_focus) == "boolean", "diff_opts.keep_terminal_focus must be a boolean")
+  end
 
   -- Validate env
   assert(type(config.env) == "table", "env must be a table")
diff --git a/lua/claudecode/terminal.lua b/lua/claudecode/terminal.lua
index 65273db2..61c93580 100644
--- a/lua/claudecode/terminal.lua
+++ b/lua/claudecode/terminal.lua
@@ -143,7 +143,13 @@ local function get_provider()
       -- Check availability based on our config instead of provider's internal state
       local external_cmd = defaults.provider_opts and defaults.provider_opts.external_terminal_cmd
 
-      local has_external_cmd = external_cmd and external_cmd ~= "" and external_cmd:find("%%s")
+      local has_external_cmd = false
+      if type(external_cmd) == "function" then
+        has_external_cmd = true
+      elseif type(external_cmd) == "string" and external_cmd ~= "" and external_cmd:find("%%s") then
+        has_external_cmd = true
+      end
+
       if has_external_cmd then
         return external_provider
       else
@@ -328,7 +334,7 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
         defaults[k] = defaults[k] or {}
         for opt_k, opt_v in pairs(v) do
           if opt_k == "external_terminal_cmd" then
-            if opt_v == nil or type(opt_v) == "string" then
+            if opt_v == nil or type(opt_v) == "string" or type(opt_v) == "function" then
               defaults[k][opt_k] = opt_v
             else
               vim.notify(
diff --git a/lua/claudecode/terminal/external.lua b/lua/claudecode/terminal/external.lua
index 8c960e58..45217469 100644
--- a/lua/claudecode/terminal/external.lua
+++ b/lua/claudecode/terminal/external.lua
@@ -39,7 +39,7 @@ function M.open(cmd_string, env_table)
   -- Get external terminal command from provider_opts
   local external_cmd = config.provider_opts and config.provider_opts.external_terminal_cmd
 
-  if not external_cmd or external_cmd == "" then
+  if not external_cmd then
     vim.notify(
       "external_terminal_cmd not configured. Please set terminal.provider_opts.external_terminal_cmd in your config.",
       vim.log.levels.ERROR
@@ -47,16 +47,54 @@ function M.open(cmd_string, env_table)
     return
   end
 
-  -- Replace %s in the template with the Claude command
-  if not external_cmd:find("%%s") then
-    vim.notify("external_terminal_cmd must contain '%s' placeholder for the Claude command.", vim.log.levels.ERROR)
+  local cmd_parts
+  local full_command
+
+  -- Handle both string and function types
+  if type(external_cmd) == "function" then
+    -- Call the function with the Claude command and env table
+    local result = external_cmd(cmd_string, env_table)
+    if not result then
+      vim.notify("external_terminal_cmd function returned nil or false", vim.log.levels.ERROR)
+      return
+    end
+
+    -- Result can be either a string or a table
+    if type(result) == "string" then
+      -- Parse the string into command parts
+      cmd_parts = vim.split(result, " ")
+      full_command = result
+    elseif type(result) == "table" then
+      -- Use the table directly as command parts
+      cmd_parts = result
+      full_command = table.concat(result, " ")
+    else
+      vim.notify(
+        "external_terminal_cmd function must return a string or table, got: " .. type(result),
+        vim.log.levels.ERROR
+      )
+      return
+    end
+  elseif type(external_cmd) == "string" then
+    if external_cmd == "" then
+      vim.notify("external_terminal_cmd string cannot be empty", vim.log.levels.ERROR)
+      return
+    end
+
+    -- Replace %s in the template with the Claude command
+    if not external_cmd:find("%%s") then
+      vim.notify("external_terminal_cmd must contain '%s' placeholder for the Claude command.", vim.log.levels.ERROR)
+      return
+    end
+
+    -- Build command by replacing %s with Claude command and splitting
+    full_command = string.format(external_cmd, cmd_string)
+    cmd_parts = vim.split(full_command, " ")
+  else
+    vim.notify("external_terminal_cmd must be a string or function, got: " .. type(external_cmd), vim.log.levels.ERROR)
     return
   end
 
-  -- Build command by replacing %s with Claude command and splitting
-  local full_command = string.format(external_cmd, cmd_string)
-  local cmd_parts = vim.split(full_command, " ")
-
   -- Start the external terminal as a detached process
   jobid = vim.fn.jobstart(cmd_parts, {
     detach = true,
diff --git a/lua/claudecode/types.lua b/lua/claudecode/types.lua
index 2dad7791..c45b1887 100644
--- a/lua/claudecode/types.lua
+++ b/lua/claudecode/types.lua
@@ -36,7 +36,7 @@
 
 -- Terminal provider-specific options
 ---@class ClaudeCodeTerminalProviderOptions
----@field external_terminal_cmd string? Command template for external terminal (e.g., "alacritty -e %s")
+---@field external_terminal_cmd string|fun(cmd: string, env: table): string|table|nil Command for external terminal (string template with %s or function)
 
 -- @ mention queued for Claude Code
 ---@class ClaudeCodeMention
diff --git a/tests/busted_setup.lua b/tests/busted_setup.lua
index a6d6795d..244b54a5 100644
--- a/tests/busted_setup.lua
+++ b/tests/busted_setup.lua
@@ -53,6 +53,13 @@ _G.expect = function(value)
     to_be_truthy = function()
       assert.is_truthy(value)
     end,
+    to_match = function(pattern)
+      assert.is_string(value)
+      assert.is_true(
+        string.find(value, pattern, 1, true) ~= nil,
+        "Expected string '" .. value .. "' to match pattern '" .. pattern .. "'"
+      )
+    end,
   }
 end
 
diff --git a/tests/unit/config_spec.lua b/tests/unit/config_spec.lua
index 3e0d63c2..94886b31 100644
--- a/tests/unit/config_spec.lua
+++ b/tests/unit/config_spec.lua
@@ -6,6 +6,7 @@ describe("Configuration", function()
 
   local function setup()
     package.loaded["claudecode.config"] = nil
+    package.loaded["claudecode.terminal"] = nil
 
     config = require("claudecode.config")
   end
@@ -196,5 +197,113 @@ describe("Configuration", function()
     expect(success).to_be_false()
   end)
 
+  it("should accept function for external_terminal_cmd", function()
+    local valid_config = {
+      port_range = { min = 10000, max = 65535 },
+      auto_start = true,
+      log_level = "info",
+      track_selection = true,
+      visual_demotion_delay_ms = 50,
+      connection_wait_delay = 200,
+      connection_timeout = 10000,
+      queue_timeout = 5000,
+      diff_opts = {
+        auto_close_on_accept = true,
+        show_diff_stats = true,
+        vertical_split = true,
+        open_in_current_tab = true,
+      },
+      env = {},
+      models = {
+        { name = "Test Model", value = "test" },
+      },
+      terminal = {
+        provider = "external",
+        provider_opts = {
+          external_terminal_cmd = function(cmd, env)
+            return "terminal " .. cmd
+          end,
+        },
+      },
+    }
+
+    local success, _ = pcall(function()
+      config.validate(valid_config)
+    end)
+
+    expect(success).to_be_true()
+  end)
+
+  it("should accept string for external_terminal_cmd", function()
+    local valid_config = {
+      port_range = { min = 10000, max = 65535 },
+      auto_start = true,
+      log_level = "info",
+      track_selection = true,
+      visual_demotion_delay_ms = 50,
+      connection_wait_delay = 200,
+      connection_timeout = 10000,
+      queue_timeout = 5000,
+      diff_opts = {
+        auto_close_on_accept = true,
+        show_diff_stats = true,
+        vertical_split = true,
+        open_in_current_tab = true,
+      },
+      env = {},
+      models = {
+        { name = "Test Model", value = "test" },
+      },
+      terminal = {
+        provider = "external",
+        provider_opts = {
+          external_terminal_cmd = "alacritty -e %s",
+        },
+      },
+    }
+
+    local success, _ = pcall(function()
+      config.validate(valid_config)
+    end)
+
+    expect(success).to_be_true()
+  end)
+
+  it("should reject invalid type for external_terminal_cmd", function()
+    local invalid_config = {
+      port_range = { min = 10000, max = 65535 },
+      auto_start = true,
+      log_level = "info",
+      track_selection = true,
+      visual_demotion_delay_ms = 50,
+      connection_wait_delay = 200,
+      connection_timeout = 10000,
+      queue_timeout = 5000,
+      diff_opts = {
+        auto_close_on_accept = true,
+        show_diff_stats = true,
+        vertical_split = true,
+        open_in_current_tab = true,
+      },
+      env = {},
+      models = {
+        { name = "Test Model", value = "test" },
+      },
+      terminal = {
+        provider = "external",
+        provider_opts = {
+          external_terminal_cmd = 123, -- Invalid: number
+        },
+      },
+    }
+
+    local success, err = pcall(function()
+      config.validate(invalid_config)
+    end)
+
+    expect(success).to_be_false()
+    expect(tostring(err)).to_match("must be a string or function")
+  end)
+
   teardown()
 end)
diff --git a/tests/unit/terminal/external_spec.lua b/tests/unit/terminal/external_spec.lua
new file mode 100644
index 00000000..97f9f0b4
--- /dev/null
+++ b/tests/unit/terminal/external_spec.lua
@@ -0,0 +1,289 @@
+describe("claudecode.terminal.external", function()
+  local external_provider
+  local mock_vim
+  local original_vim
+  local spy
+
+  before_each(function()
+    -- Store original vim global
+    original_vim = vim
+
+    -- Create spy module
+    spy = require("luassert.spy")
+
+    -- Create mock vim
+    mock_vim = {
+      fn = {
+        jobstart = spy.new(function()
+          return 123
+        end), -- Return valid job id
+        jobstop = spy.new(function() end),
+      },
+      notify = spy.new(function() end),
+      log = {
+        levels = {
+          ERROR = 3,
+          WARN = 2,
+          INFO = 1,
+          DEBUG = 0,
+        },
+      },
+      split = function(str, sep)
+        local result = {}
+        for part in string.gmatch(str, "[^" .. sep .. "]+") do
+          table.insert(result, part)
+        end
+        return result
+      end,
+      schedule = function(fn)
+        fn()
+      end,
+    }
+
+    -- Set global vim to mock
+    _G.vim = mock_vim
+
+    -- Clear package cache and reload module
+    package.loaded["claudecode.terminal.external"] = nil
+    package.loaded["claudecode.logger"] = nil
+
+    -- Mock logger
+    package.loaded["claudecode.logger"] = {
+      debug = spy.new(function() end),
+      info = spy.new(function() end),
+      warn = spy.new(function() end),
+      error = spy.new(function() end),
+    }
+
+    external_provider = require("claudecode.terminal.external")
+  end)
+
+  after_each(function()
+    -- Restore original vim
+    _G.vim = original_vim
+  end)
+
+  describe("setup", function()
+    it("should store config", function()
+      local config = {
+        provider_opts = {
+          external_terminal_cmd = "alacritty -e %s",
+        },
+      }
+      external_provider.setup(config)
+      -- Setup doesn't return anything, just verify it doesn't error
+      assert(true)
+    end)
+  end)
+
+  describe("open with string command", function()
+    it("should handle string command with %s placeholder", function()
+      local config = {
+        provider_opts = {
+          external_terminal_cmd = "alacritty -e %s",
+        },
+      }
+      external_provider.setup(config)
+
+      external_provider.open("claude --help", { ENABLE_IDE_INTEGRATION = "true" })
+
+      assert.spy(mock_vim.fn.jobstart).was_called(1)
+      local call_args = mock_vim.fn.jobstart.calls[1].vals
+      assert.are.same({ "alacritty", "-e", "claude", "--help" }, call_args[1])
+      assert.are.same({ ENABLE_IDE_INTEGRATION = "true" }, call_args[2].env)
+    end)
+
+    it("should error if string command missing %s placeholder", function()
+      local config = {
+        provider_opts = {
+          external_terminal_cmd = "alacritty -e claude",
+        },
+      }
+      external_provider.setup(config)
+
+      external_provider.open("claude --help", {})
+
+      assert
+        .spy(mock_vim.notify)
+        .was_called_with("external_terminal_cmd must contain '%s' placeholder for the Claude command.", mock_vim.log.levels.ERROR)
+      assert.spy(mock_vim.fn.jobstart).was_not_called()
+    end)
+
+    it("should error if string command is empty", function()
+      local config = {
+        provider_opts = {
+          external_terminal_cmd = "",
+        },
+      }
+      external_provider.setup(config)
+
+      external_provider.open("claude", {})
+
+      assert.spy(mock_vim.notify).was_called()
+      assert.spy(mock_vim.fn.jobstart).was_not_called()
+    end)
+  end)
+
+  describe("open with function command", function()
+    it("should handle function returning string", function()
+      local config = {
+        provider_opts = {
+          external_terminal_cmd = function(cmd, env)
+            return "kitty " .. cmd
+          end,
+        },
+      }
+      external_provider.setup(config)
+
+      external_provider.open("claude --help", { ENABLE_IDE_INTEGRATION = "true" })
+
+      assert.spy(mock_vim.fn.jobstart).was_called(1)
+      local call_args = mock_vim.fn.jobstart.calls[1].vals
+      assert.are.same({ "kitty", "claude", "--help" }, call_args[1])
+      assert.are.same({ ENABLE_IDE_INTEGRATION = "true" }, call_args[2].env)
+    end)
+
+    it("should handle function returning table", function()
+      local config = {
+        provider_opts = {
+          external_terminal_cmd = function(cmd, env)
+            return { "osascript", "-e", 'tell app "Terminal" to do script "' .. cmd .. '"' }
+          end,
+        },
+      }
+      external_provider.setup(config)
+
+      external_provider.open("claude", { ENABLE_IDE_INTEGRATION = "true" })
+
+      assert.spy(mock_vim.fn.jobstart).was_called(1)
+      local call_args = mock_vim.fn.jobstart.calls[1].vals
+      assert.are.same({ "osascript", "-e", 'tell app "Terminal" to do script "claude"' }, call_args[1])
+    end)
+
+    it("should pass cmd and env to function", function()
+      local received_cmd, received_env
+      local config = {
+        provider_opts = {
+          external_terminal_cmd = function(cmd, env)
+            received_cmd = cmd
+            received_env = env
+            return "terminal " .. cmd
+          end,
+        },
+      }
+      external_provider.setup(config)
+
+      local test_env = { ENABLE_IDE_INTEGRATION = "true", CLAUDE_CODE_SSE_PORT = "12345" }
+      external_provider.open("claude --resume", test_env)
+
+      assert.are.equal("claude --resume", received_cmd)
+      assert.are.same(test_env, received_env)
+    end)
+
+    it("should error if function returns nil", function()
+      local config = {
+        provider_opts = {
+          external_terminal_cmd = function(cmd, env)
+            return nil
+          end,
+        },
+      }
+      external_provider.setup(config)
+
+      external_provider.open("claude", {})
+
+      assert
+        .spy(mock_vim.notify)
+        .was_called_with("external_terminal_cmd function returned nil or false", mock_vim.log.levels.ERROR)
+      assert.spy(mock_vim.fn.jobstart).was_not_called()
+    end)
+
+    it("should error if function returns invalid type", function()
+      local config = {
+        provider_opts = {
+          external_terminal_cmd = function(cmd, env)
+            return 123 -- Invalid: number
+          end,
+        },
+      }
+      external_provider.setup(config)
+
+      external_provider.open("claude", {})
+
+      assert
+        .spy(mock_vim.notify)
+        .was_called_with("external_terminal_cmd function must return a string or table, got: number", mock_vim.log.levels.ERROR)
+      assert.spy(mock_vim.fn.jobstart).was_not_called()
+    end)
+  end)
+
+  describe("open with invalid config", function()
+    it("should error if external_terminal_cmd not configured", function()
+      external_provider.setup({})
+
+      external_provider.open("claude", {})
+
+      assert.spy(mock_vim.notify).was_called_with(
+        "external_terminal_cmd not configured. Please set terminal.provider_opts.external_terminal_cmd in your config.",
+        mock_vim.log.levels.ERROR
+      )
+      assert.spy(mock_vim.fn.jobstart).was_not_called()
+    end)
+
+    it("should error if external_terminal_cmd is invalid type", function()
+      local config = {
+        provider_opts = {
+          external_terminal_cmd = 123, -- Invalid: number
+        },
+      }
+      external_provider.setup(config)
+
+      external_provider.open("claude", {})
+
+      assert
+        .spy(mock_vim.notify)
+        .was_called_with("external_terminal_cmd must be a string or function, got: number", mock_vim.log.levels.ERROR)
+      assert.spy(mock_vim.fn.jobstart).was_not_called()
+    end)
+  end)
+
+  describe("close", function()
+    it("should stop job if valid", function()
+      local config = {
+        provider_opts = {
+          external_terminal_cmd = "alacritty -e %s",
+        },
+      }
+      external_provider.setup(config)
+
+      -- Start a terminal
+      external_provider.open("claude", {})
+
+      -- Close it
+      external_provider.close()
+
+      assert.spy(mock_vim.fn.jobstop).was_called_with(123)
+    end)
+
+    it("should not error if no job running", function()
+      external_provider.close()
+      assert.spy(mock_vim.fn.jobstop).was_not_called()
+    end)
+  end)
+
+  describe("other methods", function()
+    it("get_active_bufnr should return nil for external terminals", function()
+      assert.is_nil(external_provider.get_active_bufnr())
+    end)
+
+    it("is_available should return true", function()
+      assert.is_true(external_provider.is_available())
+    end)
+
+    it("ensure_visible should be a no-op", function()
+      -- Should not error
+      external_provider.ensure_visible()
+      assert(true)
+    end)
+  end)
+end)
