Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 6492393

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

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
@@ -285,6 +285,39 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
285285
}
286286
```
287287

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

290323
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
@@ -197,18 +201,67 @@ local function build_config(opts_override)
197201
snacks_win_opts = function(val)
198202
return type(val) == "table"
199203
end,
204+
cwd = function(val)
205+
return val == nil or type(val) == "string"
206+
end,
207+
git_repo_cwd = function(val)
208+
return type(val) == "boolean"
209+
end,
210+
cwd_provider = function(val)
211+
local t = type(val)
212+
if t == "function" then
213+
return true
214+
end
215+
if t == "table" then
216+
local mt = getmetatable(val)
217+
return mt and mt.__call ~= nil
218+
end
219+
return false
220+
end,
200221
}
201222
for key, val in pairs(opts_override) do
202223
if effective_config[key] ~= nil and validators[key] and validators[key](val) then
203224
effective_config[key] = val
204225
end
205226
end
206227
end
228+
-- Resolve cwd at config-build time so providers receive it directly
229+
local cwd_ctx = {
230+
file = (function()
231+
local path = vim.fn.expand("%:p")
232+
if type(path) == "string" and path ~= "" then
233+
return path
234+
end
235+
return nil
236+
end)(),
237+
cwd = vim.fn.getcwd(),
238+
}
239+
cwd_ctx.file_dir = cwd_ctx.file and vim.fn.fnamemodify(cwd_ctx.file, ":h") or nil
240+
241+
local resolved_cwd = nil
242+
-- Prefer provider function, then static cwd, then git root via resolver
243+
if effective_config.cwd_provider then
244+
local ok_p, res = pcall(effective_config.cwd_provider, cwd_ctx)
245+
if ok_p and type(res) == "string" and res ~= "" then
246+
resolved_cwd = vim.fn.expand(res)
247+
end
248+
end
249+
if not resolved_cwd and type(effective_config.cwd) == "string" and effective_config.cwd ~= "" then
250+
resolved_cwd = vim.fn.expand(effective_config.cwd)
251+
end
252+
if not resolved_cwd and effective_config.git_repo_cwd then
253+
local ok_r, cwd_mod = pcall(require, "claudecode.cwd")
254+
if ok_r and cwd_mod and type(cwd_mod.git_root) == "function" then
255+
resolved_cwd = cwd_mod.git_root(cwd_ctx.file_dir or cwd_ctx.cwd)
256+
end
257+
end
258+
207259
return {
208260
split_side = effective_config.split_side,
209261
split_width_percentage = effective_config.split_width_percentage,
210262
auto_close = effective_config.auto_close,
211263
snacks_win_opts = effective_config.snacks_win_opts,
264+
cwd = resolved_cwd,
212265
}
213266
end
214267

@@ -325,9 +378,30 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
325378
end
326379

327380
for k, v in pairs(user_term_config) do
328-
if k == "terminal_cmd" then
329-
-- terminal_cmd is handled above, skip
330-
break
381+
if k == "split_side" then
382+
if v == "left" or v == "right" then
383+
defaults.split_side = v
384+
else
385+
vim.notify("claudecode.terminal.setup: Invalid value for split_side: " .. tostring(v), vim.log.levels.WARN)
386+
end
387+
elseif k == "split_width_percentage" then
388+
if type(v) == "number" and v > 0 and v < 1 then
389+
defaults.split_width_percentage = v
390+
else
391+
vim.notify(
392+
"claudecode.terminal.setup: Invalid value for split_width_percentage: " .. tostring(v),
393+
vim.log.levels.WARN
394+
)
395+
end
396+
elseif k == "provider" then
397+
if type(v) == "table" or v == "snacks" or v == "native" or v == "external" or v == "auto" then
398+
defaults.provider = v
399+
else
400+
vim.notify(
401+
"claudecode.terminal.setup: Invalid value for provider: " .. tostring(v) .. ". Defaulting to 'native'.",
402+
vim.log.levels.WARN
403+
)
404+
end
331405
elseif k == "provider_opts" then
332406
-- Handle nested provider options
333407
if type(v) == "table" then
@@ -350,26 +424,60 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
350424
else
351425
vim.notify("claudecode.terminal.setup: Invalid value for provider_opts: " .. tostring(v), vim.log.levels.WARN)
352426
end
353-
elseif defaults[k] ~= nil then -- Other known config keys
354-
if k == "split_side" and (v == "left" or v == "right") then
355-
defaults[k] = v
356-
elseif k == "split_width_percentage" and type(v) == "number" and v > 0 and v < 1 then
357-
defaults[k] = v
358-
elseif
359-
k == "provider" and (v == "snacks" or v == "native" or v == "external" or v == "auto" or type(v) == "table")
360-
then
361-
defaults[k] = v
362-
elseif k == "show_native_term_exit_tip" and type(v) == "boolean" then
363-
defaults[k] = v
364-
elseif k == "auto_close" and type(v) == "boolean" then
365-
defaults[k] = v
366-
elseif k == "snacks_win_opts" and type(v) == "table" then
367-
defaults[k] = v
427+
elseif k == "show_native_term_exit_tip" then
428+
if type(v) == "boolean" then
429+
defaults.show_native_term_exit_tip = v
430+
else
431+
vim.notify(
432+
"claudecode.terminal.setup: Invalid value for show_native_term_exit_tip: " .. tostring(v),
433+
vim.log.levels.WARN
434+
)
435+
end
436+
elseif k == "auto_close" then
437+
if type(v) == "boolean" then
438+
defaults.auto_close = v
439+
else
440+
vim.notify("claudecode.terminal.setup: Invalid value for auto_close: " .. tostring(v), vim.log.levels.WARN)
441+
end
442+
elseif k == "snacks_win_opts" then
443+
if type(v) == "table" then
444+
defaults.snacks_win_opts = v
445+
else
446+
vim.notify("claudecode.terminal.setup: Invalid value for snacks_win_opts", vim.log.levels.WARN)
447+
end
448+
elseif k == "cwd" then
449+
if v == nil or type(v) == "string" then
450+
defaults.cwd = v
451+
else
452+
vim.notify("claudecode.terminal.setup: Invalid value for cwd: " .. tostring(v), vim.log.levels.WARN)
453+
end
454+
elseif k == "git_repo_cwd" then
455+
if type(v) == "boolean" then
456+
defaults.git_repo_cwd = v
368457
else
369-
vim.notify("claudecode.terminal.setup: Invalid value for " .. k .. ": " .. tostring(v), vim.log.levels.WARN)
458+
vim.notify("claudecode.terminal.setup: Invalid value for git_repo_cwd: " .. tostring(v), vim.log.levels.WARN)
459+
end
460+
elseif k == "cwd_provider" then
461+
local t = type(v)
462+
if t == "function" then
463+
defaults.cwd_provider = v
464+
elseif t == "table" then
465+
local mt = getmetatable(v)
466+
if mt and mt.__call then
467+
defaults.cwd_provider = v
468+
else
469+
vim.notify(
470+
"claudecode.terminal.setup: cwd_provider table is not callable (missing __call)",
471+
vim.log.levels.WARN
472+
)
473+
end
474+
else
475+
vim.notify("claudecode.terminal.setup: Invalid cwd_provider type: " .. tostring(t), vim.log.levels.WARN)
370476
end
371477
else
372-
vim.notify("claudecode.terminal.setup: Unknown configuration key: " .. k, vim.log.levels.WARN)
478+
if k ~= "terminal_cmd" then
479+
vim.notify("claudecode.terminal.setup: Unknown configuration key: " .. k, vim.log.levels.WARN)
480+
end
373481
end
374482
end
375483

‎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)