Skip to content

Commit e2c03ba

Browse files
committed
feat: add working directory control for Claude terminal
Change-Id: I0cc3cf3815bc5634a6c01f4d708e0ccda8e53404 Signed-off-by: Thomas Kosiewski <[email protected]>
1 parent f756826 commit e2c03ba

File tree

8 files changed

+354
-16
lines changed

8 files changed

+354
-16
lines changed

README.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,39 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
276276
}
277277
```
278278

279+
### Working Directory Control
280+
281+
You can fix the Claude terminal's working directory regardless of `autochdir` and buffer-local cwd changes. Options (precedence order):
282+
283+
- `cwd_provider(ctx)`: function that returns a directory string. Receives `{ file, file_dir, cwd }`.
284+
- `cwd`: static path to use as working directory.
285+
- `git_repo_cwd = true`: resolves git root from the current file directory (or cwd if no file).
286+
287+
Examples:
288+
289+
```lua
290+
require("claudecode").setup({
291+
-- Top-level aliases are supported and forwarded to terminal config
292+
git_repo_cwd = true,
293+
})
294+
295+
require("claudecode").setup({
296+
terminal = {
297+
cwd = vim.fn.expand("~/projects/my-app"),
298+
},
299+
})
300+
301+
require("claudecode").setup({
302+
terminal = {
303+
cwd_provider = function(ctx)
304+
-- Prefer repo root; fallback to file's directory
305+
local cwd = require("claudecode.cwd").git_root(ctx.file_dir or ctx.cwd) or ctx.file_dir or ctx.cwd
306+
return cwd
307+
end,
308+
},
309+
})
310+
```
311+
279312
## Floating Window Configuration
280313

281314
The `snacks_win_opts` configuration allows you to create floating Claude Code terminals with custom positioning, sizing, and key bindings. Here are several practical examples:

lua/claudecode/cwd.lua

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
--- Working directory resolution helpers for ClaudeCode.nvim
2+
---@module 'claudecode.cwd'
3+
4+
local M = {}
5+
6+
---Normalize and validate a directory path
7+
---@param dir string|nil
8+
---@return string|nil
9+
local function normalize_dir(dir)
10+
if type(dir) ~= "string" or dir == "" then
11+
return nil
12+
end
13+
-- Expand ~ and similar
14+
local expanded = vim.fn.expand(dir)
15+
local isdir = 1
16+
if vim.fn.isdirectory then
17+
isdir = vim.fn.isdirectory(expanded)
18+
end
19+
if isdir == 1 then
20+
return expanded
21+
end
22+
return nil
23+
end
24+
25+
---Find the git repository root starting from a directory
26+
---@param start_dir string|nil
27+
---@return string|nil
28+
function M.git_root(start_dir)
29+
start_dir = normalize_dir(start_dir)
30+
if not start_dir then
31+
return nil
32+
end
33+
34+
-- Prefer running without shell by passing a list
35+
local result
36+
if vim.fn.systemlist then
37+
local ok, _ = pcall(function()
38+
local _ = vim.fn.systemlist({ "git", "-C", start_dir, "rev-parse", "--show-toplevel" })
39+
end)
40+
if ok then
41+
result = vim.fn.systemlist({ "git", "-C", start_dir, "rev-parse", "--show-toplevel" })
42+
else
43+
-- Fallback to string command if needed
44+
local cmd = "git -C " .. vim.fn.shellescape(start_dir) .. " rev-parse --show-toplevel"
45+
result = vim.fn.systemlist(cmd)
46+
end
47+
end
48+
49+
if vim.v.shell_error == 0 and result and #result > 0 then
50+
local root = normalize_dir(result[1])
51+
if root then
52+
return root
53+
end
54+
end
55+
56+
-- Fallback: search for .git directory upward
57+
if vim.fn.finddir then
58+
local git_dir = vim.fn.finddir(".git", start_dir .. ";")
59+
if type(git_dir) == "string" and git_dir ~= "" then
60+
local parent = vim.fn.fnamemodify(git_dir, ":h")
61+
return normalize_dir(parent)
62+
end
63+
end
64+
65+
return nil
66+
end
67+
68+
---Resolve the effective working directory based on terminal config and context
69+
---@param term_cfg ClaudeCodeTerminalConfig
70+
---@param ctx ClaudeCodeCwdContext
71+
---@return string|nil
72+
function M.resolve(term_cfg, ctx)
73+
if type(term_cfg) ~= "table" then
74+
return nil
75+
end
76+
77+
-- 1) Custom provider takes precedence
78+
local provider = term_cfg.cwd_provider
79+
local provider_type = type(provider)
80+
if provider_type == "function" then
81+
local ok, res = pcall(provider, ctx)
82+
if ok then
83+
local p = normalize_dir(res)
84+
if p then
85+
return p
86+
end
87+
end
88+
end
89+
90+
-- 2) Static cwd
91+
local static_cwd = normalize_dir(term_cfg.cwd)
92+
if static_cwd then
93+
return static_cwd
94+
end
95+
96+
-- 3) Git repository root
97+
if term_cfg.git_repo_cwd then
98+
local start_dir = ctx and (ctx.file_dir or ctx.cwd) or vim.fn.getcwd()
99+
local root = M.git_root(start_dir)
100+
if root then
101+
return root
102+
end
103+
end
104+
105+
-- 4) No override
106+
return nil
107+
end
108+
109+
return M

lua/claudecode/init.lua

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,27 @@ function M.setup(opts)
300300

301301
-- Setup terminal module: always try to call setup to pass terminal_cmd and env,
302302
-- even if terminal_opts (for split_side etc.) are not provided.
303+
-- Map top-level cwd-related aliases into terminal config for convenience
304+
do
305+
local t = opts.terminal or {}
306+
local had_alias = false
307+
if opts.git_repo_cwd ~= nil then
308+
t.git_repo_cwd = opts.git_repo_cwd
309+
had_alias = true
310+
end
311+
if opts.cwd ~= nil then
312+
t.cwd = opts.cwd
313+
had_alias = true
314+
end
315+
if opts.cwd_provider ~= nil then
316+
t.cwd_provider = opts.cwd_provider
317+
had_alias = true
318+
end
319+
if had_alias then
320+
opts.terminal = t
321+
end
322+
end
323+
303324
local terminal_setup_ok, terminal_module = pcall(require, "claudecode.terminal")
304325
if terminal_setup_ok then
305326
-- Guard in case tests or user replace the module with a minimal stub without `setup`.

lua/claudecode/terminal.lua

Lines changed: 129 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ local defaults = {
1616
auto_close = true,
1717
env = {},
1818
snacks_win_opts = {},
19+
-- Working directory control
20+
cwd = nil, -- static cwd override
21+
git_repo_cwd = false, -- resolve to git root when spawning
22+
cwd_provider = nil, -- function(ctx) -> cwd string
1923
}
2024

2125
M.defaults = defaults
@@ -172,18 +176,67 @@ local function build_config(opts_override)
172176
snacks_win_opts = function(val)
173177
return type(val) == "table"
174178
end,
179+
cwd = function(val)
180+
return val == nil or type(val) == "string"
181+
end,
182+
git_repo_cwd = function(val)
183+
return type(val) == "boolean"
184+
end,
185+
cwd_provider = function(val)
186+
local t = type(val)
187+
if t == "function" then
188+
return true
189+
end
190+
if t == "table" then
191+
local mt = getmetatable(val)
192+
return mt and mt.__call ~= nil
193+
end
194+
return false
195+
end,
175196
}
176197
for key, val in pairs(opts_override) do
177198
if effective_config[key] ~= nil and validators[key] and validators[key](val) then
178199
effective_config[key] = val
179200
end
180201
end
181202
end
203+
-- Resolve cwd at config-build time so providers receive it directly
204+
local cwd_ctx = {
205+
file = (function()
206+
local path = vim.fn.expand("%:p")
207+
if type(path) == "string" and path ~= "" then
208+
return path
209+
end
210+
return nil
211+
end)(),
212+
cwd = vim.fn.getcwd(),
213+
}
214+
cwd_ctx.file_dir = cwd_ctx.file and vim.fn.fnamemodify(cwd_ctx.file, ":h") or nil
215+
216+
local resolved_cwd = nil
217+
-- Prefer provider function, then static cwd, then git root via resolver
218+
if effective_config.cwd_provider then
219+
local ok_p, res = pcall(effective_config.cwd_provider, cwd_ctx)
220+
if ok_p and type(res) == "string" and res ~= "" then
221+
resolved_cwd = vim.fn.expand(res)
222+
end
223+
end
224+
if not resolved_cwd and type(effective_config.cwd) == "string" and effective_config.cwd ~= "" then
225+
resolved_cwd = vim.fn.expand(effective_config.cwd)
226+
end
227+
if not resolved_cwd and effective_config.git_repo_cwd then
228+
local ok_r, cwd_mod = pcall(require, "claudecode.cwd")
229+
if ok_r and cwd_mod and type(cwd_mod.git_root) == "function" then
230+
resolved_cwd = cwd_mod.git_root(cwd_ctx.file_dir or cwd_ctx.cwd)
231+
end
232+
end
233+
182234
return {
183235
split_side = effective_config.split_side,
184236
split_width_percentage = effective_config.split_width_percentage,
185237
auto_close = effective_config.auto_close,
186238
snacks_win_opts = effective_config.snacks_win_opts,
239+
cwd = resolved_cwd,
187240
}
188241
end
189242

@@ -300,24 +353,84 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
300353
end
301354

302355
for k, v in pairs(user_term_config) do
303-
if defaults[k] ~= nil and k ~= "terminal_cmd" then -- terminal_cmd is handled above
304-
if k == "split_side" and (v == "left" or v == "right") then
305-
defaults[k] = v
306-
elseif k == "split_width_percentage" and type(v) == "number" and v > 0 and v < 1 then
307-
defaults[k] = v
308-
elseif k == "provider" and (v == "snacks" or v == "native" or v == "auto" or type(v) == "table") then
309-
defaults[k] = v
310-
elseif k == "show_native_term_exit_tip" and type(v) == "boolean" then
311-
defaults[k] = v
312-
elseif k == "auto_close" and type(v) == "boolean" then
313-
defaults[k] = v
314-
elseif k == "snacks_win_opts" and type(v) == "table" then
315-
defaults[k] = v
356+
if k == "split_side" then
357+
if v == "left" or v == "right" then
358+
defaults.split_side = v
359+
else
360+
vim.notify("claudecode.terminal.setup: Invalid value for split_side: " .. tostring(v), vim.log.levels.WARN)
361+
end
362+
elseif k == "split_width_percentage" then
363+
if type(v) == "number" and v > 0 and v < 1 then
364+
defaults.split_width_percentage = v
365+
else
366+
vim.notify(
367+
"claudecode.terminal.setup: Invalid value for split_width_percentage: " .. tostring(v),
368+
vim.log.levels.WARN
369+
)
370+
end
371+
elseif k == "provider" then
372+
if type(v) == "table" or v == "snacks" or v == "native" or v == "auto" then
373+
defaults.provider = v
316374
else
317-
vim.notify("claudecode.terminal.setup: Invalid value for " .. k .. ": " .. tostring(v), vim.log.levels.WARN)
375+
vim.notify(
376+
"claudecode.terminal.setup: Invalid value for provider: " .. tostring(v) .. ". Defaulting to 'native'.",
377+
vim.log.levels.WARN
378+
)
379+
end
380+
elseif k == "show_native_term_exit_tip" then
381+
if type(v) == "boolean" then
382+
defaults.show_native_term_exit_tip = v
383+
else
384+
vim.notify(
385+
"claudecode.terminal.setup: Invalid value for show_native_term_exit_tip: " .. tostring(v),
386+
vim.log.levels.WARN
387+
)
388+
end
389+
elseif k == "auto_close" then
390+
if type(v) == "boolean" then
391+
defaults.auto_close = v
392+
else
393+
vim.notify("claudecode.terminal.setup: Invalid value for auto_close: " .. tostring(v), vim.log.levels.WARN)
394+
end
395+
elseif k == "snacks_win_opts" then
396+
if type(v) == "table" then
397+
defaults.snacks_win_opts = v
398+
else
399+
vim.notify("claudecode.terminal.setup: Invalid value for snacks_win_opts", vim.log.levels.WARN)
400+
end
401+
elseif k == "cwd" then
402+
if v == nil or type(v) == "string" then
403+
defaults.cwd = v
404+
else
405+
vim.notify("claudecode.terminal.setup: Invalid value for cwd: " .. tostring(v), vim.log.levels.WARN)
406+
end
407+
elseif k == "git_repo_cwd" then
408+
if type(v) == "boolean" then
409+
defaults.git_repo_cwd = v
410+
else
411+
vim.notify("claudecode.terminal.setup: Invalid value for git_repo_cwd: " .. tostring(v), vim.log.levels.WARN)
412+
end
413+
elseif k == "cwd_provider" then
414+
local t = type(v)
415+
if t == "function" then
416+
defaults.cwd_provider = v
417+
elseif t == "table" then
418+
local mt = getmetatable(v)
419+
if mt and mt.__call then
420+
defaults.cwd_provider = v
421+
else
422+
vim.notify(
423+
"claudecode.terminal.setup: cwd_provider table is not callable (missing __call)",
424+
vim.log.levels.WARN
425+
)
426+
end
427+
else
428+
vim.notify("claudecode.terminal.setup: Invalid cwd_provider type: " .. tostring(t), vim.log.levels.WARN)
429+
end
430+
else
431+
if k ~= "terminal_cmd" then
432+
vim.notify("claudecode.terminal.setup: Unknown configuration key: " .. k, vim.log.levels.WARN)
318433
end
319-
elseif k ~= "terminal_cmd" then -- Avoid warning for terminal_cmd if passed in user_term_config
320-
vim.notify("claudecode.terminal.setup: Unknown configuration key: " .. k, vim.log.levels.WARN)
321434
end
322435
end
323436

lua/claudecode/terminal/native.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ local function open_terminal(cmd_string, env_table, effective_config, focus)
8888

8989
jobid = vim.fn.termopen(term_cmd_arg, {
9090
env = env_table,
91+
cwd = effective_config.cwd,
9192
on_exit = function(job_id, _, _)
9293
vim.schedule(function()
9394
if job_id == jobid then

lua/claudecode/terminal/snacks.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ local function build_opts(config, env_table, focus)
5050
focus = utils.normalize_focus(focus)
5151
return {
5252
env = env_table,
53+
cwd = config.cwd,
5354
start_insert = focus,
5455
auto_insert = focus,
5556
auto_close = false,

lua/claudecode/types.lua

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,14 @@
4040
-- In-tree terminal provider names
4141
---@alias ClaudeCodeTerminalProviderName "auto"|"snacks"|"native"
4242

43+
-- Working directory resolution context and provider
44+
---@class ClaudeCodeCwdContext
45+
---@field file string|nil -- absolute path of current buffer file (if any)
46+
---@field file_dir string|nil -- directory of current buffer file (if any)
47+
---@field cwd string -- current Neovim working directory
48+
49+
---@alias ClaudeCodeCwdProvider fun(ctx: ClaudeCodeCwdContext): string|nil
50+
4351
-- @ mention queued for Claude Code
4452
---@class ClaudeCodeMention
4553
---@field file_path string The absolute file path to mention
@@ -70,6 +78,9 @@
7078
---@field auto_close boolean
7179
---@field env table<string, string>
7280
---@field snacks_win_opts snacks.win.Config
81+
---@field cwd string|nil -- static working directory for Claude terminal
82+
---@field git_repo_cwd boolean|nil -- use git root of current file/cwd as working directory
83+
---@field cwd_provider? ClaudeCodeCwdProvider -- custom function to compute working directory
7384

7485
-- Port range configuration
7586
---@class ClaudeCodePortRange

0 commit comments

Comments
 (0)