Skip to content

feat: add working directory control for Claude terminal #117

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: thomask33/feat_add_Shift_Enter_keybinding_for_new_line_in_terminal
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,39 @@ For deep technical details, see [ARCHITECTURE.md](./ARCHITECTURE.md).
}
```

### Working Directory Control

You can fix the Claude terminal's working directory regardless of `autochdir` and buffer-local cwd changes. Options (precedence order):

- `cwd_provider(ctx)`: function that returns a directory string. Receives `{ file, file_dir, cwd }`.
- `cwd`: static path to use as working directory.
- `git_repo_cwd = true`: resolves git root from the current file directory (or cwd if no file).

Examples:

```lua
require("claudecode").setup({
-- Top-level aliases are supported and forwarded to terminal config
git_repo_cwd = true,
})

require("claudecode").setup({
terminal = {
cwd = vim.fn.expand("~/projects/my-app"),
},
})

require("claudecode").setup({
terminal = {
cwd_provider = function(ctx)
-- Prefer repo root; fallback to file's directory
local cwd = require("claudecode.cwd").git_root(ctx.file_dir or ctx.cwd) or ctx.file_dir or ctx.cwd
return cwd
end,
},
})
```

## Floating Window Configuration

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:
Expand Down
109 changes: 109 additions & 0 deletions lua/claudecode/cwd.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
--- Working directory resolution helpers for ClaudeCode.nvim
---@module 'claudecode.cwd'

local M = {}

---Normalize and validate a directory path
---@param dir string|nil
---@return string|nil
local function normalize_dir(dir)
if type(dir) ~= "string" or dir == "" then
return nil
end
-- Expand ~ and similar
local expanded = vim.fn.expand(dir)
local isdir = 1
if vim.fn.isdirectory then
isdir = vim.fn.isdirectory(expanded)
end
if isdir == 1 then
return expanded
end
return nil
end

---Find the git repository root starting from a directory
---@param start_dir string|nil
---@return string|nil
function M.git_root(start_dir)
start_dir = normalize_dir(start_dir)
if not start_dir then
return nil
end

-- Prefer running without shell by passing a list
local result
if vim.fn.systemlist then
local ok, _ = pcall(function()
local _ = vim.fn.systemlist({ "git", "-C", start_dir, "rev-parse", "--show-toplevel" })
end)
if ok then
result = vim.fn.systemlist({ "git", "-C", start_dir, "rev-parse", "--show-toplevel" })
else
-- Fallback to string command if needed
local cmd = "git -C " .. vim.fn.shellescape(start_dir) .. " rev-parse --show-toplevel"
result = vim.fn.systemlist(cmd)
end
end

if vim.v.shell_error == 0 and result and #result > 0 then
local root = normalize_dir(result[1])
if root then
return root
end
end

-- Fallback: search for .git directory upward
if vim.fn.finddir then
local git_dir = vim.fn.finddir(".git", start_dir .. ";")
if type(git_dir) == "string" and git_dir ~= "" then
local parent = vim.fn.fnamemodify(git_dir, ":h")
return normalize_dir(parent)
end
end

return nil
end

---Resolve the effective working directory based on terminal config and context
---@param term_cfg ClaudeCodeTerminalConfig
---@param ctx ClaudeCodeCwdContext
---@return string|nil
function M.resolve(term_cfg, ctx)
if type(term_cfg) ~= "table" then
return nil
end

-- 1) Custom provider takes precedence
local provider = term_cfg.cwd_provider
local provider_type = type(provider)
if provider_type == "function" then
local ok, res = pcall(provider, ctx)
if ok then
local p = normalize_dir(res)
if p then
return p
end
end
end

-- 2) Static cwd
local static_cwd = normalize_dir(term_cfg.cwd)
if static_cwd then
return static_cwd
end

-- 3) Git repository root
if term_cfg.git_repo_cwd then
local start_dir = ctx and (ctx.file_dir or ctx.cwd) or vim.fn.getcwd()
local root = M.git_root(start_dir)
if root then
return root
end
end

-- 4) No override
return nil
end

return M
21 changes: 21 additions & 0 deletions lua/claudecode/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,27 @@ function M.setup(opts)

-- Setup terminal module: always try to call setup to pass terminal_cmd and env,
-- even if terminal_opts (for split_side etc.) are not provided.
-- Map top-level cwd-related aliases into terminal config for convenience
do
local t = opts.terminal or {}
local had_alias = false
if opts.git_repo_cwd ~= nil then
t.git_repo_cwd = opts.git_repo_cwd
had_alias = true
end
if opts.cwd ~= nil then
t.cwd = opts.cwd
had_alias = true
end
if opts.cwd_provider ~= nil then
t.cwd_provider = opts.cwd_provider
had_alias = true
end
if had_alias then
opts.terminal = t
end
end

local terminal_setup_ok, terminal_module = pcall(require, "claudecode.terminal")
if terminal_setup_ok then
-- Guard in case tests or user replace the module with a minimal stub without `setup`.
Expand Down
148 changes: 128 additions & 20 deletions lua/claudecode/terminal.lua
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ local defaults = {
auto_close = true,
env = {},
snacks_win_opts = {},
-- Working directory control
cwd = nil, -- static cwd override
git_repo_cwd = false, -- resolve to git root when spawning
cwd_provider = nil, -- function(ctx) -> cwd string
}

M.defaults = defaults
Expand Down Expand Up @@ -191,18 +195,67 @@ local function build_config(opts_override)
snacks_win_opts = function(val)
return type(val) == "table"
end,
cwd = function(val)
return val == nil or type(val) == "string"
end,
git_repo_cwd = function(val)
return type(val) == "boolean"
end,
cwd_provider = function(val)
local t = type(val)
if t == "function" then
return true
end
if t == "table" then
local mt = getmetatable(val)
return mt and mt.__call ~= nil
end
return false
end,
}
for key, val in pairs(opts_override) do
if effective_config[key] ~= nil and validators[key] and validators[key](val) then
effective_config[key] = val
end
end
end
-- Resolve cwd at config-build time so providers receive it directly
local cwd_ctx = {
file = (function()
local path = vim.fn.expand("%:p")
if type(path) == "string" and path ~= "" then
return path
end
return nil
end)(),
cwd = vim.fn.getcwd(),
}
cwd_ctx.file_dir = cwd_ctx.file and vim.fn.fnamemodify(cwd_ctx.file, ":h") or nil

local resolved_cwd = nil
-- Prefer provider function, then static cwd, then git root via resolver
if effective_config.cwd_provider then
local ok_p, res = pcall(effective_config.cwd_provider, cwd_ctx)
if ok_p and type(res) == "string" and res ~= "" then
resolved_cwd = vim.fn.expand(res)
end
end
if not resolved_cwd and type(effective_config.cwd) == "string" and effective_config.cwd ~= "" then
resolved_cwd = vim.fn.expand(effective_config.cwd)
end
if not resolved_cwd and effective_config.git_repo_cwd then
local ok_r, cwd_mod = pcall(require, "claudecode.cwd")
if ok_r and cwd_mod and type(cwd_mod.git_root) == "function" then
resolved_cwd = cwd_mod.git_root(cwd_ctx.file_dir or cwd_ctx.cwd)
end
end

return {
split_side = effective_config.split_side,
split_width_percentage = effective_config.split_width_percentage,
auto_close = effective_config.auto_close,
snacks_win_opts = effective_config.snacks_win_opts,
cwd = resolved_cwd,
}
end

Expand Down Expand Up @@ -319,9 +372,30 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
end

for k, v in pairs(user_term_config) do
if k == "terminal_cmd" then
-- terminal_cmd is handled above, skip
break
if k == "split_side" then
if v == "left" or v == "right" then
defaults.split_side = v
else
vim.notify("claudecode.terminal.setup: Invalid value for split_side: " .. tostring(v), vim.log.levels.WARN)
end
elseif k == "split_width_percentage" then
if type(v) == "number" and v > 0 and v < 1 then
defaults.split_width_percentage = v
else
vim.notify(
"claudecode.terminal.setup: Invalid value for split_width_percentage: " .. tostring(v),
vim.log.levels.WARN
)
end
elseif k == "provider" then
if type(v) == "table" or v == "snacks" or v == "native" or v == "external" or v == "auto" then
defaults.provider = v
else
vim.notify(
"claudecode.terminal.setup: Invalid value for provider: " .. tostring(v) .. ". Defaulting to 'native'.",
vim.log.levels.WARN
)
end
elseif k == "provider_opts" then
-- Handle nested provider options
if type(v) == "table" then
Expand All @@ -344,26 +418,60 @@ function M.setup(user_term_config, p_terminal_cmd, p_env)
else
vim.notify("claudecode.terminal.setup: Invalid value for provider_opts: " .. tostring(v), vim.log.levels.WARN)
end
elseif defaults[k] ~= nil then -- Other known config keys
if k == "split_side" and (v == "left" or v == "right") then
defaults[k] = v
elseif k == "split_width_percentage" and type(v) == "number" and v > 0 and v < 1 then
defaults[k] = v
elseif
k == "provider" and (v == "snacks" or v == "native" or v == "external" or v == "auto" or type(v) == "table")
then
defaults[k] = v
elseif k == "show_native_term_exit_tip" and type(v) == "boolean" then
defaults[k] = v
elseif k == "auto_close" and type(v) == "boolean" then
defaults[k] = v
elseif k == "snacks_win_opts" and type(v) == "table" then
defaults[k] = v
elseif k == "show_native_term_exit_tip" then
if type(v) == "boolean" then
defaults.show_native_term_exit_tip = v
else
vim.notify(
"claudecode.terminal.setup: Invalid value for show_native_term_exit_tip: " .. tostring(v),
vim.log.levels.WARN
)
end
elseif k == "auto_close" then
if type(v) == "boolean" then
defaults.auto_close = v
else
vim.notify("claudecode.terminal.setup: Invalid value for auto_close: " .. tostring(v), vim.log.levels.WARN)
end
elseif k == "snacks_win_opts" then
if type(v) == "table" then
defaults.snacks_win_opts = v
else
vim.notify("claudecode.terminal.setup: Invalid value for snacks_win_opts", vim.log.levels.WARN)
end
elseif k == "cwd" then
if v == nil or type(v) == "string" then
defaults.cwd = v
else
vim.notify("claudecode.terminal.setup: Invalid value for cwd: " .. tostring(v), vim.log.levels.WARN)
end
elseif k == "git_repo_cwd" then
if type(v) == "boolean" then
defaults.git_repo_cwd = v
else
vim.notify("claudecode.terminal.setup: Invalid value for " .. k .. ": " .. tostring(v), vim.log.levels.WARN)
vim.notify("claudecode.terminal.setup: Invalid value for git_repo_cwd: " .. tostring(v), vim.log.levels.WARN)
end
elseif k == "cwd_provider" then
local t = type(v)
if t == "function" then
defaults.cwd_provider = v
elseif t == "table" then
local mt = getmetatable(v)
if mt and mt.__call then
defaults.cwd_provider = v
else
vim.notify(
"claudecode.terminal.setup: cwd_provider table is not callable (missing __call)",
vim.log.levels.WARN
)
end
else
vim.notify("claudecode.terminal.setup: Invalid cwd_provider type: " .. tostring(t), vim.log.levels.WARN)
end
else
vim.notify("claudecode.terminal.setup: Unknown configuration key: " .. k, vim.log.levels.WARN)
if k ~= "terminal_cmd" then
vim.notify("claudecode.terminal.setup: Unknown configuration key: " .. k, vim.log.levels.WARN)
end
end
end

Expand Down
1 change: 1 addition & 0 deletions lua/claudecode/terminal/native.lua
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ local function open_terminal(cmd_string, env_table, effective_config, focus)

jobid = vim.fn.termopen(term_cmd_arg, {
env = env_table,
cwd = effective_config.cwd,
on_exit = function(job_id, _, _)
vim.schedule(function()
if job_id == jobid then
Expand Down
1 change: 1 addition & 0 deletions lua/claudecode/terminal/snacks.lua
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ local function build_opts(config, env_table, focus)
focus = utils.normalize_focus(focus)
return {
env = env_table,
cwd = config.cwd,
start_insert = focus,
auto_insert = focus,
auto_close = false,
Expand Down
Loading