Skip to content

Commit e1945e6

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

File tree

8 files changed

+353
-20
lines changed

8 files changed

+353
-20
lines changed

README.md

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

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

286319
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: 128 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ local defaults = {
1919
auto_close = true,
2020
env = {},
2121
snacks_win_opts = {},
22+
-- Working directory control
23+
cwd = nil, -- static cwd override
24+
git_repo_cwd = false, -- resolve to git root when spawning
25+
cwd_provider = nil, -- function(ctx) -> cwd string
2226
}
2327

2428
M.defaults = defaults
@@ -191,18 +195,67 @@ local function build_config(opts_override)
191195
snacks_win_opts = function(val)
192196
return type(val) == "table"
193197
end,
198+
cwd = function(val)
199+
return val == nil or type(val) == "string"
200+
end,
201+
git_repo_cwd = function(val)
202+
return type(val) == "boolean"
203+
end,
204+
cwd_provider = function(val)
205+
local t = type(val)
206+
if t == "function" then
207+
return true
208+
end
209+
if t == "table" then
210+
local mt = getmetatable(val)
211+
return mt and mt.__call ~= nil
212+
end
213+
return false
214+
end,
194215
}
195216
for key, val in pairs(opts_override) do
196217
if effective_config[key] ~= nil and validators[key] and validators[key](val) then
197218
effective_config[key] = val
198219
end
199220
end
200221
end
222+
-- Resolve cwd at config-build time so providers receive it directly
223+
local cwd_ctx = {
224+
file = (function()
225+
local path = vim.fn.expand("%:p")
226+
if type(path) == "string" and path ~= "" then
227+
return path
228+
end
229+
return nil
230+
end)(),
231+
cwd = vim.fn.getcwd(),
232+
}
233+
cwd_ctx.file_dir = cwd_ctx.file and vim.fn.fnamemodify(cwd_ctx.file, ":h") or nil
234+
235+
local resolved_cwd = nil
236+
-- Prefer provider function, then static cwd, then git root via resolver
237+
if effective_config.cwd_provider then
238+
local ok_p, res = pcall(effective_config.cwd_provider, cwd_ctx)
239+
if ok_p and type(res) == "string" and res ~= "" then
240+
resolved_cwd = vim.fn.expand(res)
241+
end
242+
end
243+
if not resolved_cwd and type(effective_config.cwd) == "string" and effective_config.cwd ~= "" then
244+
resolved_cwd = vim.fn.expand(effective_config.cwd)
245+
end
246+
if not resolved_cwd and effective_config.git_repo_cwd then
247+
local ok_r, cwd_mod = pcall(require, "claudecode.cwd")
248+
if ok_r and cwd_mod and type(cwd_mod.git_root) == "function" then
249+
resolved_cwd = cwd_mod.git_root(cwd_ctx.file_dir or cwd_ctx.cwd)
250+
end
251+
end
252+
201253
return {
202254
split_side = effective_config.split_side,
203255
split_width_percentage = effective_config.split_width_percentage,
204256
auto_close = effective_config.auto_close,
205257
snacks_win_opts = effective_config.snacks_win_opts,
258+
cwd = resolved_cwd,
206259
}
207260
end
208261

@@ -319,9 +372,30 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
319372
end
320373

321374
for k, v in pairs(user_term_config) do
322-
if k == "terminal_cmd" then
323-
-- terminal_cmd is handled above, skip
324-
break
375+
if k == "split_side" then
376+
if v == "left" or v == "right" then
377+
defaults.split_side = v
378+
else
379+
vim.notify("claudecode.terminal.setup: Invalid value for split_side: " .. tostring(v), vim.log.levels.WARN)
380+
end
381+
elseif k == "split_width_percentage" then
382+
if type(v) == "number" and v > 0 and v < 1 then
383+
defaults.split_width_percentage = v
384+
else
385+
vim.notify(
386+
"claudecode.terminal.setup: Invalid value for split_width_percentage: " .. tostring(v),
387+
vim.log.levels.WARN
388+
)
389+
end
390+
elseif k == "provider" then
391+
if type(v) == "table" or v == "snacks" or v == "native" or v == "external" or v == "auto" then
392+
defaults.provider = v
393+
else
394+
vim.notify(
395+
"claudecode.terminal.setup: Invalid value for provider: " .. tostring(v) .. ". Defaulting to 'native'.",
396+
vim.log.levels.WARN
397+
)
398+
end
325399
elseif k == "provider_opts" then
326400
-- Handle nested provider options
327401
if type(v) == "table" then
@@ -344,26 +418,60 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
344418
else
345419
vim.notify("claudecode.terminal.setup: Invalid value for provider_opts: " .. tostring(v), vim.log.levels.WARN)
346420
end
347-
elseif defaults[k] ~= nil then -- Other known config keys
348-
if k == "split_side" and (v == "left" or v == "right") then
349-
defaults[k] = v
350-
elseif k == "split_width_percentage" and type(v) == "number" and v > 0 and v < 1 then
351-
defaults[k] = v
352-
elseif
353-
k == "provider" and (v == "snacks" or v == "native" or v == "external" or v == "auto" or type(v) == "table")
354-
then
355-
defaults[k] = v
356-
elseif k == "show_native_term_exit_tip" and type(v) == "boolean" then
357-
defaults[k] = v
358-
elseif k == "auto_close" and type(v) == "boolean" then
359-
defaults[k] = v
360-
elseif k == "snacks_win_opts" and type(v) == "table" then
361-
defaults[k] = v
421+
elseif k == "show_native_term_exit_tip" then
422+
if type(v) == "boolean" then
423+
defaults.show_native_term_exit_tip = v
424+
else
425+
vim.notify(
426+
"claudecode.terminal.setup: Invalid value for show_native_term_exit_tip: " .. tostring(v),
427+
vim.log.levels.WARN
428+
)
429+
end
430+
elseif k == "auto_close" then
431+
if type(v) == "boolean" then
432+
defaults.auto_close = v
433+
else
434+
vim.notify("claudecode.terminal.setup: Invalid value for auto_close: " .. tostring(v), vim.log.levels.WARN)
435+
end
436+
elseif k == "snacks_win_opts" then
437+
if type(v) == "table" then
438+
defaults.snacks_win_opts = v
439+
else
440+
vim.notify("claudecode.terminal.setup: Invalid value for snacks_win_opts", vim.log.levels.WARN)
441+
end
442+
elseif k == "cwd" then
443+
if v == nil or type(v) == "string" then
444+
defaults.cwd = v
445+
else
446+
vim.notify("claudecode.terminal.setup: Invalid value for cwd: " .. tostring(v), vim.log.levels.WARN)
447+
end
448+
elseif k == "git_repo_cwd" then
449+
if type(v) == "boolean" then
450+
defaults.git_repo_cwd = v
362451
else
363-
vim.notify("claudecode.terminal.setup: Invalid value for " .. k .. ": " .. tostring(v), vim.log.levels.WARN)
452+
vim.notify("claudecode.terminal.setup: Invalid value for git_repo_cwd: " .. tostring(v), vim.log.levels.WARN)
453+
end
454+
elseif k == "cwd_provider" then
455+
local t = type(v)
456+
if t == "function" then
457+
defaults.cwd_provider = v
458+
elseif t == "table" then
459+
local mt = getmetatable(v)
460+
if mt and mt.__call then
461+
defaults.cwd_provider = v
462+
else
463+
vim.notify(
464+
"claudecode.terminal.setup: cwd_provider table is not callable (missing __call)",
465+
vim.log.levels.WARN
466+
)
467+
end
468+
else
469+
vim.notify("claudecode.terminal.setup: Invalid cwd_provider type: " .. tostring(t), vim.log.levels.WARN)
364470
end
365471
else
366-
vim.notify("claudecode.terminal.setup: Unknown configuration key: " .. k, vim.log.levels.WARN)
472+
if k ~= "terminal_cmd" then
473+
vim.notify("claudecode.terminal.setup: Unknown configuration key: " .. k, vim.log.levels.WARN)
474+
end
367475
end
368476
end
369477

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,

0 commit comments

Comments
 (0)