Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit 6492393

Browse files
feat: add working directory control for Claude terminal
Change-Id: I0cc3cf3815bc5634a6c01f4d708e0ccda8e53404 Signed-off-by: Thomas Kosiewski <tk@coder.com>
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)

AltStyle によって変換されたページ (->オリジナル) /