Skip to content

Commit e21a837

Browse files
committed
feat(terminal/external): add cwd support and stricter placeholder parsing; set jobstart cwd; update docs/tests
Change-Id: If71a96214bb10d361fccaaeb5415080a5df3125c Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent e737c52 commit e21a837

File tree

3 files changed

+81
-8
lines changed

3 files changed

+81
-8
lines changed

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -264,8 +264,9 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
264264
-- Provider-specific options
265265
provider_opts = {
266266
-- Command for external terminal provider. Can be:
267-
-- 1. String with %s placeholder: "alacritty -e %s"
268-
-- 2. Function returning command: function(cmd, env) return "alacritty -e " .. cmd end
267+
-- 1. String with %s placeholder: "alacritty -e %s" (backward compatible)
268+
-- 2. String with two %s placeholders: "alacritty --working-directory %s -e %s" (cwd, command)
269+
-- 3. Function returning command: function(cmd, env) return "alacritty -e " .. cmd end
269270
external_terminal_cmd = nil,
270271
},
271272
},
@@ -463,6 +464,7 @@ Run Claude Code in a separate terminal application outside of Neovim:
463464
provider = "external",
464465
provider_opts = {
465466
external_terminal_cmd = "alacritty -e %s", -- %s is replaced with claude command
467+
-- Or with working directory: "alacritty --working-directory %s -e %s" (first %s = cwd, second %s = command)
466468
},
467469
},
468470
},
@@ -603,6 +605,8 @@ require("claudecode").setup({
603605

604606
The custom provider will automatically fall back to the native provider if validation fails or `is_available()` returns false.
605607

608+
Note: If your command or working directory may contain spaces or special characters, prefer returning a table of args from a function (e.g., `{ "alacritty", "--working-directory", cwd, "-e", "claude", "--help" }`) to avoid shell-quoting issues.
609+
606610
## Community Extensions
607611

608612
The following are third-party community extensions that complement claudecode.nvim. **These extensions are not affiliated with Coder and are maintained independently by community members.** We do not ensure that these extensions work correctly or provide support for them.

lua/claudecode/terminal/external.lua

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ function M.open(cmd_string, env_table)
4949

5050
local cmd_parts
5151
local full_command
52+
local cwd_for_jobstart = nil
5253

5354
-- Handle both string and function types
5455
if type(external_cmd) == "function" then
@@ -81,24 +82,47 @@ function M.open(cmd_string, env_table)
8182
return
8283
end
8384

84-
-- Replace %s in the template with the Claude command
85-
if not external_cmd:find("%%s") then
86-
vim.notify("external_terminal_cmd must contain '%s' placeholder for the Claude command.", vim.log.levels.ERROR)
85+
-- Count the number of %s placeholders and format accordingly
86+
-- 1 placeholder: backward compatible, just command ("alacritty -e %s")
87+
-- 2 placeholders: cwd and command ("alacritty --working-directory %s -e %s")
88+
local _, placeholder_count = external_cmd:gsub("%%s", "")
89+
90+
if placeholder_count == 0 then
91+
vim.notify("external_terminal_cmd must contain '%s' placeholder(s) for the command.", vim.log.levels.ERROR)
92+
return
93+
elseif placeholder_count == 1 then
94+
-- Backward compatible: just the command
95+
full_command = string.format(external_cmd, cmd_string)
96+
elseif placeholder_count == 2 then
97+
-- New feature: cwd and command
98+
local cwd = vim.fn.getcwd()
99+
cwd_for_jobstart = cwd
100+
full_command = string.format(external_cmd, cwd, cmd_string)
101+
else
102+
vim.notify(
103+
string.format(
104+
"external_terminal_cmd must use 1 '%%s' (command) or 2 '%%s' placeholders (cwd, command); got %d",
105+
placeholder_count
106+
),
107+
vim.log.levels.ERROR
108+
)
87109
return
88110
end
89111

90-
-- Build command by replacing %s with Claude command and splitting
91-
full_command = string.format(external_cmd, cmd_string)
92112
cmd_parts = vim.split(full_command, " ")
93113
else
94114
vim.notify("external_terminal_cmd must be a string or function, got: " .. type(external_cmd), vim.log.levels.ERROR)
95115
return
96116
end
97117

98118
-- Start the external terminal as a detached process
119+
-- Set cwd for jobstart when available to improve robustness even if the terminal ignores it
120+
cwd_for_jobstart = cwd_for_jobstart or (vim.fn.getcwd and vim.fn.getcwd() or nil)
121+
99122
jobid = vim.fn.jobstart(cmd_parts, {
100123
detach = true,
101124
env = env_table,
125+
cwd = cwd_for_jobstart,
102126
on_exit = function(job_id, exit_code, _)
103127
vim.schedule(function()
104128
if job_id == jobid then

tests/unit/terminal/external_spec.lua

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ describe("claudecode.terminal.external", function()
1818
return 123
1919
end), -- Return valid job id
2020
jobstop = spy.new(function() end),
21+
getcwd = spy.new(function()
22+
return "/cwd"
23+
end),
2124
},
2225
notify = spy.new(function() end),
2326
log = {
@@ -91,6 +94,7 @@ describe("claudecode.terminal.external", function()
9194
local call_args = mock_vim.fn.jobstart.calls[1].vals
9295
assert.are.same({ "alacritty", "-e", "claude", "--help" }, call_args[1])
9396
assert.are.same({ ENABLE_IDE_INTEGRATION = "true" }, call_args[2].env)
97+
assert.are.equal("/cwd", call_args[2].cwd)
9498
end)
9599

96100
it("should error if string command missing %s placeholder", function()
@@ -105,7 +109,7 @@ describe("claudecode.terminal.external", function()
105109

106110
assert
107111
.spy(mock_vim.notify)
108-
.was_called_with("external_terminal_cmd must contain '%s' placeholder for the Claude command.", mock_vim.log.levels.ERROR)
112+
.was_called_with("external_terminal_cmd must contain '%s' placeholder(s) for the command.", mock_vim.log.levels.ERROR)
109113
assert.spy(mock_vim.fn.jobstart).was_not_called()
110114
end)
111115

@@ -122,6 +126,45 @@ describe("claudecode.terminal.external", function()
122126
assert.spy(mock_vim.notify).was_called()
123127
assert.spy(mock_vim.fn.jobstart).was_not_called()
124128
end)
129+
130+
it("should handle string with two placeholders (cwd and command)", function()
131+
-- Mock vim.fn.getcwd to return a known directory
132+
mock_vim.fn.getcwd = spy.new(function()
133+
return "/test/project"
134+
end)
135+
136+
local config = {
137+
provider_opts = {
138+
external_terminal_cmd = "alacritty --working-directory %s -e %s",
139+
},
140+
}
141+
external_provider.setup(config)
142+
143+
external_provider.open("claude --help", { ENABLE_IDE_INTEGRATION = "true" })
144+
145+
assert.spy(mock_vim.fn.jobstart).was_called(1)
146+
local call_args = mock_vim.fn.jobstart.calls[1].vals
147+
assert.are.same({ "alacritty", "--working-directory", "/test/project", "-e", "claude", "--help" }, call_args[1])
148+
assert.are.same({ ENABLE_IDE_INTEGRATION = "true" }, call_args[2].env)
149+
assert.are.equal("/test/project", call_args[2].cwd)
150+
end)
151+
152+
it("should error if string has more than two placeholders", function()
153+
local config = {
154+
provider_opts = {
155+
external_terminal_cmd = "alacritty --working-directory %s -e %s --title %s",
156+
},
157+
}
158+
external_provider.setup(config)
159+
160+
external_provider.open("claude --help", {})
161+
162+
assert.spy(mock_vim.notify).was_called_with(
163+
"external_terminal_cmd must use 1 '%s' (command) or 2 '%s' placeholders (cwd, command); got 3",
164+
mock_vim.log.levels.ERROR
165+
)
166+
assert.spy(mock_vim.fn.jobstart).was_not_called()
167+
end)
125168
end)
126169

127170
describe("open with function command", function()
@@ -141,6 +184,7 @@ describe("claudecode.terminal.external", function()
141184
local call_args = mock_vim.fn.jobstart.calls[1].vals
142185
assert.are.same({ "kitty", "claude", "--help" }, call_args[1])
143186
assert.are.same({ ENABLE_IDE_INTEGRATION = "true" }, call_args[2].env)
187+
assert.are.equal("/cwd", call_args[2].cwd)
144188
end)
145189

146190
it("should handle function returning table", function()
@@ -158,6 +202,7 @@ describe("claudecode.terminal.external", function()
158202
assert.spy(mock_vim.fn.jobstart).was_called(1)
159203
local call_args = mock_vim.fn.jobstart.calls[1].vals
160204
assert.are.same({ "osascript", "-e", 'tell app "Terminal" to do script "claude"' }, call_args[1])
205+
assert.are.equal("/cwd", call_args[2].cwd)
161206
end)
162207

163208
it("should pass cmd and env to function", function()

0 commit comments

Comments
 (0)