From 1ade7f5f1a41f36e838fd3f343c6e211346812c1 Mon Sep 17 00:00:00 2001 From: Luke Chow Date: Mon, 2 Mar 2026 21:11:58 +0800 Subject: [PATCH] feat: add sandbox options and send text selection --- README.md | 15 ++ lua/codex/init.lua | 265 ++++++++++++++++++++++++++++++-- tests/specs/codex_spec.lua | 302 +++++++++++++++++++++++++++++++++++++ 3 files changed, 566 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 47339a4..da192c2 100644 --- a/README.md +++ b/README.md @@ -39,16 +39,25 @@ return { desc = 'Toggle Codex popup or side-panel', mode = { 'n', 't' } }, + { + 'cs', -- Optional visual-mode mapping to send selected text to Codex + function() require('codex').send_selection() end, + desc = 'Send selection to Codex', + mode = 'x' + }, }, opts = { keymaps = { toggle = nil, -- Keybind to toggle Codex window (Disabled by default, watch out for conflicts) quit = '', -- Keybind to close the Codex window (default: Ctrl + q) + send_selection = nil, -- Visual-mode keybind to send the current selection to Codex }, -- Disable internal default keymap (cc -> :CodexToggle) border = 'rounded', -- Options: 'single', 'double', or 'rounded' width = 0.8, -- Width of the floating window (0.0 to 1.0) height = 0.8, -- Height of the floating window (0.0 to 1.0) model = nil, -- Optional: pass a string to use a specific model (e.g., 'o3-mini') + sandbox = nil, -- Optional default sandbox: 'read-only', 'workspace-write', or 'danger-full-access' + approval = nil, -- Optional default approval policy: 'untrusted', 'on-failure', 'on-request', or 'never' autoinstall = true, -- Automatically install the Codex CLI if not found panel = false, -- Open Codex in a side-panel (vertical split) instead of floating window use_buffer = false, -- Capture Codex stdout into a normal buffer instead of a terminal buffer @@ -58,6 +67,12 @@ return { ### Usage: - Call `:Codex` (or `:CodexToggle`) to open or close the Codex popup or side-panel. - Map your own keybindings via the `keymaps.toggle` setting. +- Use `:CodexSendSelection` in visual mode, or configure `keymaps.send_selection`, to send highlighted text to Codex. +- If a Codex chat is already running, the selection is sent to that existing session and the window is focused immediately. +- If no Codex chat is running, the selection starts a new session as the initial prompt. +- To set a default Codex sandbox, use `sandbox = 'workspace-write'` (or `read-only` / `danger-full-access`) in setup. +- To set a default approval policy, use `approval = 'on-request'` (or `untrusted` / `on-failure` / `never`) in setup. +- To override launch options for a single session, run commands like `:Codex danger-full-access on-request` or `:CodexToggle workspace-write never`. - To choose floating popup vs side-panel, set `panel = false` (popup) or `panel = true` (panel) in your setup options. - To capture Codex output in an editable buffer instead of a terminal, set `use_buffer = true` (or `false` to keep terminal) in your setup options. - Add the following code to show backgrounded Codex window in lualine: diff --git a/lua/codex/init.lua b/lua/codex/init.lua index 4378b46..fdf5631 100644 --- a/lua/codex/init.lua +++ b/lua/codex/init.lua @@ -8,31 +8,229 @@ local config = { keymaps = { toggle = nil, quit = '', -- Default: Ctrl+q to quit + send_selection = nil, }, border = 'single', width = 0.8, height = 0.8, cmd = 'codex', model = nil, -- Default to the latest model + sandbox = nil, -- Optional default sandbox mode for Codex CLI + approval = nil, -- Optional default approval policy for Codex CLI autoinstall = true, panel = false, -- if true, open Codex in a side-panel instead of floating window use_buffer = false, -- if true, capture Codex stdout into a normal buffer instead of a terminal } +local valid_sandbox_modes = { + ['read-only'] = true, + ['workspace-write'] = true, + ['danger-full-access'] = true, +} + +local valid_approval_policies = { + untrusted = true, + ['on-failure'] = true, + ['on-request'] = true, + never = true, +} + +local function launch_option_completion() + return { + 'read-only', + 'workspace-write', + 'danger-full-access', + 'untrusted', + 'on-failure', + 'on-request', + 'never', + } +end + +local function parse_command_opts(args) + local launch_opts = {} + + if not args or args == '' then + return launch_opts + end + + for token in string.gmatch(args, '%S+') do + if valid_sandbox_modes[token] then + if launch_opts.sandbox and launch_opts.sandbox ~= token then + vim.notify('[codex.nvim] Multiple sandbox modes provided', vim.log.levels.ERROR) + return nil + end + launch_opts.sandbox = token + elseif valid_approval_policies[token] then + if launch_opts.approval and launch_opts.approval ~= token then + vim.notify('[codex.nvim] Multiple approval policies provided', vim.log.levels.ERROR) + return nil + end + launch_opts.approval = token + else + vim.notify('[codex.nvim] Invalid launch option: ' .. tostring(token), vim.log.levels.ERROR) + return nil + end + end + + return launch_opts +end + +local function resolve_launch_opts(opts) + local launch_opts = opts or {} + local sandbox = launch_opts.sandbox + local approval = launch_opts.approval + local prompt = launch_opts.prompt + + if sandbox == nil then + sandbox = config.sandbox + end + + if approval == nil then + approval = config.approval + end + + if sandbox ~= nil and not valid_sandbox_modes[sandbox] then + vim.notify( + '[codex.nvim] Invalid sandbox mode: ' .. tostring(sandbox), + vim.log.levels.ERROR + ) + return nil + end + + if approval ~= nil and not valid_approval_policies[approval] then + vim.notify( + '[codex.nvim] Invalid approval policy: ' .. tostring(approval), + vim.log.levels.ERROR + ) + return nil + end + + return { + sandbox = sandbox, + approval = approval, + prompt = prompt, + } +end + +local function build_cmd_args(launch_opts) + local cmd_args = type(config.cmd) == 'string' and { config.cmd } or vim.deepcopy(config.cmd) + + if config.model then + table.insert(cmd_args, '-m') + table.insert(cmd_args, config.model) + end + + if launch_opts.sandbox then + table.insert(cmd_args, '--sandbox') + table.insert(cmd_args, launch_opts.sandbox) + end + + if launch_opts.approval then + table.insert(cmd_args, '--ask-for-approval') + table.insert(cmd_args, launch_opts.approval) + end + + if launch_opts.prompt and launch_opts.prompt ~= '' then + table.insert(cmd_args, launch_opts.prompt) + end + + return cmd_args +end + +local function get_visual_selection() + local start_pos = vim.fn.getpos "'<" + local end_pos = vim.fn.getpos "'>" + local start_row = start_pos[2] + local start_col = start_pos[3] + local end_row = end_pos[2] + local end_col = end_pos[3] + + if start_row == 0 or end_row == 0 then + return nil + end + + if start_row > end_row or (start_row == end_row and start_col > end_col) then + start_row, end_row = end_row, start_row + start_col, end_col = end_col, start_col + end + + local lines = vim.api.nvim_buf_get_lines(0, start_row - 1, end_row, false) + + if vim.tbl_isempty(lines) then + return nil + end + + lines[1] = string.sub(lines[1], start_col, #lines[1]) + lines[#lines] = string.sub(lines[#lines], 1, end_col) + + return table.concat(lines, '\n') +end + +local function is_job_active(job) + return type(job) == 'number' and job > 0 and vim.fn.jobwait({ job }, 0)[1] == -1 +end + +local function reset_session() + if state.job then + vim.fn.jobstop(state.job) + state.job = nil + end + + if state.win and vim.api.nvim_win_is_valid(state.win) then + vim.api.nvim_win_close(state.win, true) + end + state.win = nil + + if state.buf and vim.api.nvim_buf_is_valid(state.buf) then + pcall(vim.api.nvim_buf_delete, state.buf, { force = true }) + end + state.buf = nil +end + function M.setup(user_config) config = vim.tbl_deep_extend('force', config, user_config or {}) - vim.api.nvim_create_user_command('Codex', function() - M.toggle() - end, { desc = 'Toggle Codex popup' }) + vim.api.nvim_create_user_command('Codex', function(opts) + local launch_opts = parse_command_opts(opts.args) + if not launch_opts then + return + end + M.toggle(launch_opts) + end, { + desc = 'Toggle Codex popup', + nargs = '*', + complete = launch_option_completion, + }) + + vim.api.nvim_create_user_command('CodexToggle', function(opts) + local launch_opts = parse_command_opts(opts.args) + if not launch_opts then + return + end + M.toggle(launch_opts) + end, { + desc = 'Toggle Codex popup (alias)', + nargs = '*', + complete = launch_option_completion, + }) - vim.api.nvim_create_user_command('CodexToggle', function() - M.toggle() - end, { desc = 'Toggle Codex popup (alias)' }) + vim.api.nvim_create_user_command('CodexSendSelection', function() + M.send_selection() + end, { + desc = 'Send the current visual selection to Codex', + range = true, + }) if config.keymaps.toggle then vim.api.nvim_set_keymap('n', config.keymaps.toggle, 'CodexToggle', { noremap = true, silent = true }) end + + if config.keymaps.send_selection then + vim.keymap.set('x', config.keymaps.send_selection, function() + M.send_selection() + end, { noremap = true, silent = true, desc = 'Send selection to Codex' }) + end end local function open_window() @@ -100,7 +298,17 @@ local function open_panel() state.win = win end -function M.open() +function M.open(opts) + local launch_opts = resolve_launch_opts(opts) + + if not launch_opts then + return + end + + if launch_opts.prompt then + reset_session() + end + local function create_clean_buf() local buf = vim.api.nvim_create_buf(false, false) @@ -130,7 +338,7 @@ function M.open() if config.autoinstall then installer.prompt_autoinstall(function(success) if success then - M.open() -- Try again after installing + M.open(launch_opts) -- Try again after installing else -- Show failure message *after* buffer is created if not state.buf or not vim.api.nvim_buf_is_valid(state.buf) then @@ -175,12 +383,7 @@ function M.open() if config.panel then open_panel() else open_window() end if not state.job then - -- assemble command - local cmd_args = type(config.cmd) == 'string' and { config.cmd } or vim.deepcopy(config.cmd) - if config.model then - table.insert(cmd_args, '-m') - table.insert(cmd_args, config.model) - end + local cmd_args = build_cmd_args(launch_opts) if config.use_buffer then -- capture stdout/stderr into normal buffer @@ -222,6 +425,36 @@ function M.open() end end +function M.send_selection(opts) + local selection = get_visual_selection() + + if not selection or selection == '' then + vim.notify('[codex.nvim] No visual selection available to send', vim.log.levels.WARN) + return + end + + if is_job_active(state.job) and not config.use_buffer then + if state.win and vim.api.nvim_win_is_valid(state.win) then + vim.api.nvim_set_current_win(state.win) + else + if config.panel then + open_panel() + else + open_window() + end + end + + vim.fn.chansend(state.job, selection .. '\n') + return + end + + local launch_opts = vim.tbl_extend('force', opts or {}, { + prompt = selection, + }) + + M.open(launch_opts) +end + function M.close() if state.win and vim.api.nvim_win_is_valid(state.win) then vim.api.nvim_win_close(state.win, true) @@ -229,11 +462,11 @@ function M.close() state.win = nil end -function M.toggle() +function M.toggle(opts) if state.win and vim.api.nvim_win_is_valid(state.win) then M.close() else - M.open() + M.open(opts) end end diff --git a/tests/specs/codex_spec.lua b/tests/specs/codex_spec.lua index 3b10db9..e859c3a 100644 --- a/tests/specs/codex_spec.lua +++ b/tests/specs/codex_spec.lua @@ -110,4 +110,306 @@ describe('codex.nvim', function() -- Restore original vim.fn = original_fn end) + + it('passes --sandbox from setup config to termopen when configured', function() + local original_fn = vim.fn + local termopen_called = false + local received_cmd = {} + + vim.fn = setmetatable({ + termopen = function(cmd, opts) + termopen_called = true + received_cmd = cmd + if type(opts.on_exit) == 'function' then + vim.defer_fn(function() + opts.on_exit(0) + end, 10) + end + return 123 + end, + }, { __index = original_fn }) + + package.loaded['codex'] = nil + package.loaded['codex.state'] = nil + local codex = require 'codex' + + codex.setup { + cmd = 'codex', + sandbox = 'workspace-write', + } + + codex.open() + + vim.wait(500, function() + return termopen_called + end, 10) + + assert(termopen_called, 'termopen should be called') + assert(vim.tbl_contains(received_cmd, '--sandbox'), 'should include --sandbox flag') + assert(vim.tbl_contains(received_cmd, 'workspace-write'), 'should include configured sandbox mode') + + vim.fn = original_fn + end) + + it('allows per-launch sandbox overrides', function() + local original_fn = vim.fn + local termopen_called = false + local received_cmd = {} + + vim.fn = setmetatable({ + termopen = function(cmd, opts) + termopen_called = true + received_cmd = cmd + if type(opts.on_exit) == 'function' then + vim.defer_fn(function() + opts.on_exit(0) + end, 10) + end + return 123 + end, + }, { __index = original_fn }) + + package.loaded['codex'] = nil + package.loaded['codex.state'] = nil + local codex = require 'codex' + + codex.setup { + cmd = 'codex', + sandbox = 'read-only', + } + + codex.open { sandbox = 'danger-full-access' } + + vim.wait(500, function() + return termopen_called + end, 10) + + assert(termopen_called, 'termopen should be called') + assert(vim.tbl_contains(received_cmd, '--sandbox'), 'should include --sandbox flag') + assert(vim.tbl_contains(received_cmd, 'danger-full-access'), 'should include override sandbox mode') + assert(not vim.tbl_contains(received_cmd, 'read-only'), 'should not keep the default sandbox mode when overridden') + + vim.fn = original_fn + end) + + it('passes --ask-for-approval from setup config to termopen when configured', function() + local original_fn = vim.fn + local termopen_called = false + local received_cmd = {} + + vim.fn = setmetatable({ + termopen = function(cmd, opts) + termopen_called = true + received_cmd = cmd + if type(opts.on_exit) == 'function' then + vim.defer_fn(function() + opts.on_exit(0) + end, 10) + end + return 123 + end, + }, { __index = original_fn }) + + package.loaded['codex'] = nil + package.loaded['codex.state'] = nil + local codex = require 'codex' + + codex.setup { + cmd = 'codex', + approval = 'on-request', + } + + codex.open() + + vim.wait(500, function() + return termopen_called + end, 10) + + assert(termopen_called, 'termopen should be called') + assert(vim.tbl_contains(received_cmd, '--ask-for-approval'), 'should include --ask-for-approval flag') + assert(vim.tbl_contains(received_cmd, 'on-request'), 'should include configured approval policy') + + vim.fn = original_fn + end) + + it('allows combined sandbox and approval overrides from commands', function() + local original_fn = vim.fn + local termopen_called = false + local received_cmd = {} + + vim.fn = setmetatable({ + termopen = function(cmd, opts) + termopen_called = true + received_cmd = cmd + if type(opts.on_exit) == 'function' then + vim.defer_fn(function() + opts.on_exit(0) + end, 10) + end + return 123 + end, + }, { __index = original_fn }) + + package.loaded['codex'] = nil + package.loaded['codex.state'] = nil + local codex = require 'codex' + + codex.setup { + cmd = 'codex', + sandbox = 'read-only', + approval = 'untrusted', + } + + vim.cmd 'Codex danger-full-access on-request' + + vim.wait(500, function() + return termopen_called + end, 10) + + assert(termopen_called, 'termopen should be called') + assert(vim.tbl_contains(received_cmd, '--sandbox'), 'should include --sandbox flag') + assert(vim.tbl_contains(received_cmd, 'danger-full-access'), 'should include override sandbox mode') + assert(vim.tbl_contains(received_cmd, '--ask-for-approval'), 'should include --ask-for-approval flag') + assert(vim.tbl_contains(received_cmd, 'on-request'), 'should include override approval policy') + assert(not vim.tbl_contains(received_cmd, 'read-only'), 'should not keep default sandbox when overridden') + assert(not vim.tbl_contains(received_cmd, 'untrusted'), 'should not keep default approval when overridden') + + vim.fn = original_fn + end) + + it('passes prompt text as the initial Codex input', function() + local original_fn = vim.fn + local termopen_called = false + local received_cmd = {} + + vim.fn = setmetatable({ + termopen = function(cmd, opts) + termopen_called = true + received_cmd = cmd + if type(opts.on_exit) == 'function' then + vim.defer_fn(function() + opts.on_exit(0) + end, 10) + end + return 123 + end, + }, { __index = original_fn }) + + package.loaded['codex'] = nil + package.loaded['codex.state'] = nil + local codex = require 'codex' + + codex.setup { + cmd = 'codex', + autoinstall = false, + } + + codex.open { prompt = 'summarize this change' } + + vim.wait(500, function() + return termopen_called + end, 10) + + assert(termopen_called, 'termopen should be called') + eq(received_cmd[#received_cmd], 'summarize this change') + + vim.fn = original_fn + end) + + it('sends the current visual selection as the initial Codex prompt', function() + local original_fn = vim.fn + local termopen_called = false + local received_cmd = {} + + vim.fn = setmetatable({ + termopen = function(cmd, opts) + termopen_called = true + received_cmd = cmd + if type(opts.on_exit) == 'function' then + vim.defer_fn(function() + opts.on_exit(0) + end, 10) + end + return 123 + end, + }, { __index = original_fn }) + + package.loaded['codex'] = nil + package.loaded['codex.state'] = nil + local codex = require 'codex' + + codex.setup { + cmd = 'codex', + autoinstall = false, + } + + local buf = vim.api.nvim_create_buf(true, false) + vim.api.nvim_set_current_buf(buf) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'first line', + 'second line', + }) + vim.fn.setpos("'<", { 0, 1, 1, 0 }) + vim.fn.setpos("'>", { 0, 2, 6, 0 }) + + codex.send_selection() + + vim.wait(500, function() + return termopen_called + end, 10) + + assert(termopen_called, 'termopen should be called') + eq(received_cmd[#received_cmd], 'first line\nsecond') + + vim.fn = original_fn + end) + + it('sends the current visual selection to an active Codex session', function() + local original_fn = vim.fn + local chansend_called = false + local sent_text = nil + + vim.fn = setmetatable({ + jobwait = function() + return { -1 } + end, + chansend = function(job, text) + chansend_called = true + eq(job, 321) + sent_text = text + end, + termopen = function() + error('termopen should not be called when reusing an active session') + end, + }, { __index = original_fn }) + + package.loaded['codex'] = nil + package.loaded['codex.state'] = nil + local codex = require 'codex' + local state = require 'codex.state' + + codex.setup { + cmd = 'codex', + autoinstall = false, + } + + state.buf = vim.api.nvim_create_buf(false, false) + state.job = 321 + state.win = nil + + local buf = vim.api.nvim_create_buf(true, false) + vim.api.nvim_set_current_buf(buf) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'reused session text', + }) + vim.fn.setpos("'<", { 0, 1, 1, 0 }) + vim.fn.setpos("'>", { 0, 1, 19, 0 }) + + codex.send_selection() + + assert(chansend_called, 'chansend should be called') + eq(sent_text, 'reused session text\n') + + vim.fn = original_fn + end) end)