diff --git a/README.md b/README.md index 96aa66a..ba0e73c 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ ## Cursor Agent Neovim Plugin -A minimal Neovim plugin to run the Cursor Agent CLI inside a centered floating terminal. Toggle an interactive terminal at your project root, or send the current buffer or a visual selection to Cursor Agent. +A minimal Neovim plugin to run the Cursor Agent CLI inside a terminal window. Toggle an interactive terminal at your project root, or send the current buffer or a visual selection to Cursor Agent. Supports both floating window and sidebar modes. ### Requirements - **Cursor Agent CLI**: `cursor-agent` available on your `$PATH` @@ -42,22 +42,39 @@ Note: The plugin auto-initializes with defaults on load (via `after/plugin/curso ## Quickstart -- Run `:CursorAgent` to toggle an interactive floating terminal in your project root. Type directly into the `cursor-agent` program. +- Run `:CursorAgent` to toggle an interactive terminal in your project root. Type directly into the `cursor-agent` program. - Visually select code, then use `:CursorAgentSelection` to ask about just that selection. - Run `:CursorAgentBuffer` to send the entire current buffer (handy for files like `cursor.md`). -- Press `q` in normal mode in the floating terminal to close it or run :CursorAgent `ca` to toggle it away. +- Press `q` in normal mode to close the terminal or run `:CursorAgent` / `ca` to toggle it away. -All interactions happen in a centered floating terminal. +By default, interactions happen in a centered floating window. You can configure the plugin to use an attached sidebar instead (see Configuration section). ## Commands -- **:CursorAgent**: Toggle the interactive Cursor Agent terminal (project root). +- **:CursorAgent**: Toggle the interactive Cursor Agent terminal (project root). Uses your configured window mode. - **:CursorAgentSelection**: Send the current visual selection (writes to a temp file and opens terminal rendering). - **:CursorAgentBuffer**: Send the full current buffer (writes to a temp file and opens terminal rendering). +### Window Mode Behavior + +#### Floating Mode (default) +- Opens a centered floating window +- Title bar shows "Cursor Agent" +- Rounded borders +- Press `q` in normal mode to close + +#### Attached Mode (Sidebar) +- Opens as a vertical split +- Can be positioned on left or right side +- Configurable width as fraction of screen (e.g., 0.2 = 20%) +- Press `q` in normal mode to close +- Window integrates with your existing split layout + ## Configuration Only set what you need. For typical usage, `cmd` and `args` are enough. + +### Basic Configuration ```lua require("cursor-agent").setup({ -- Executable or argv table. Example: "cursor-agent" or {"/usr/local/bin/cursor-agent"} @@ -67,7 +84,56 @@ require("cursor-agent").setup({ }) ``` -Advanced (for lower-level CLI helpers present in the codebase but not required for terminal mode): +### Window Mode Configuration + +The plugin supports two window modes: floating (default) and attached (sidebar). + +#### Default Configuration (Floating Window) +```lua +require("cursor-agent").setup({ + -- Default behavior - opens in floating window + window_mode = "floating", -- or omit this line for default +}) +``` + +#### Attached Mode (Sidebar) - Right Side +```lua +require("cursor-agent").setup({ + window_mode = "attached", + position = "right", -- Opens on right side + width = 0.2, -- 1/5 of screen width +}) +``` + +#### Attached Mode (Sidebar) - Left Side +```lua +require("cursor-agent").setup({ + window_mode = "attached", + position = "left", -- Opens on left side + width = 0.25, -- 1/4 of screen width +}) +``` + +### Complete Configuration Example +```lua +require("cursor-agent").setup({ + -- Standard options + cmd = "cursor-agent", + args = {}, + use_stdin = true, + multi_instance = false, + timeout_ms = 60000, + auto_scroll = true, + + -- Window mode options + window_mode = "attached", -- Use split window instead of floating + position = "right", -- Position on right side + width = 0.2, -- Use 1/5 of screen width (20%) +}) +``` + +### Advanced Options +For lower-level CLI helpers present in the codebase but not required for terminal mode: ```lua require("cursor-agent").setup({ -- Whether to send content via stdin when using non-terminal helpers @@ -118,7 +184,9 @@ This opens a floating terminal using `termopen`, with the working directory set ## How it works -- A floating terminal is created with `termopen`, centered, wrapped, and ready for immediate input. +- A terminal window is created with `termopen`, ready for immediate input. Window mode depends on your configuration: + - **Floating mode**: Creates a centered floating window with rounded borders + - **Attached mode**: Creates a vertical split positioned on the left or right side - The terminal starts in the detected project root so Cursor Agent has the right context. - For selection/buffer commands, the text is written to a temporary file and its path is passed to the CLI as a positional argument. diff --git a/doc/cursor-agent.txt b/doc/cursor-agent.txt index 57d3097..b4679e0 100644 --- a/doc/cursor-agent.txt +++ b/doc/cursor-agent.txt @@ -19,20 +19,53 @@ CONFIGURATION *cursor-agent-config* Use Lua to configure in your init.lua: > require('cursor-agent').setup({ - cmd = 'cursor-agent', -- or { 'cursor-agent', 'cli' } - args = {}, -- default extra args - use_stdin = true, -- send content via stdin - multi_instance = true, -- allow concurrent calls + cmd = 'cursor-agent', -- or { 'cursor-agent', 'cli' } + args = {}, -- default extra args + use_stdin = true, -- send content via stdin + multi_instance = true, -- allow concurrent calls timeout_ms = 60000, + window_mode = 'floating', -- 'floating' or 'attached' (split) + position = 'right', -- 'left' or 'right' (for attached mode) + width = 0.2, -- width fraction (e.g., 0.2 = 1/5 screen) + }) +< + +WINDOW MODES *cursor-agent-window-modes* + +The plugin supports two window modes: + +- `floating` (default): Opens in a centered floating window +- `attached`: Opens in a vertical split pane + +For attached mode, you can configure: +- `position`: 'left' or 'right' side of screen (default: 'right') +- `width`: fraction of screen width (default: 0.2 for 1/5 of screen) + +Examples: +> + -- Open in right split at 1/5 screen width + require('cursor-agent').setup({ + window_mode = 'attached', + position = 'right', + width = 0.2, + }) + + -- Open in left split at 1/4 screen width + require('cursor-agent').setup({ + window_mode = 'attached', + position = 'left', + width = 0.25, }) < NOTES *cursor-agent-notes* -- The plugin uses a floating terminal to render Cursor Agent's native UI. The -- terminal starts in the project root (detected via markers like `.git`). +- The plugin uses a terminal window (floating or split pane) to render Cursor + Agent's native UI. The terminal starts in the project root (detected via + markers like `.git`). - Configure `cmd` to point to your Cursor Agent CLI. If the executable is missing, an error is shown. +- In both window modes, press 'q' in normal mode to close the window. - For help tags: :helptags ALL or :helptags {plugin-doc-dir} LICENSE *cursor-agent-license* diff --git a/lua/cursor-agent/config.lua b/lua/cursor-agent/config.lua index 2d860ed..8a516f7 100644 --- a/lua/cursor-agent/config.lua +++ b/lua/cursor-agent/config.lua @@ -13,6 +13,12 @@ local default_config = { timeout_ms = 60000, -- Auto-scroll output buffer to the end as new content arrives auto_scroll = true, + -- Window mode: "floating" or "attached" (split window) + window_mode = "floating", + -- Position for attached mode: "left" or "right" + position = "right", + -- Width for attached mode (fraction of screen width, e.g., 0.2 for 1/5 of screen) + width = 0.2, } local active_config = vim.deepcopy(default_config) diff --git a/lua/cursor-agent/init.lua b/lua/cursor-agent/init.lua index 0d093ef..dded134 100644 --- a/lua/cursor-agent/init.lua +++ b/lua/cursor-agent/init.lua @@ -67,24 +67,40 @@ function M.ask(opts) end local root = util.get_project_root() - termui.open_float_term({ - argv = argv, - title = title, - border = 'rounded', - width = 0.6, - height = 0.6, - cwd = root, - on_exit = function(code) - if code ~= 0 then - util.notify(('cursor-agent exited with code %d'):format(code), vim.log.levels.WARN) - end - end, - }) + + if cfg.window_mode == "attached" then + termui.open_split_term({ + argv = argv, + position = cfg.position, + width = cfg.width, + cwd = root, + on_exit = function(code) + if code ~= 0 then + util.notify(('cursor-agent exited with code %d'):format(code), vim.log.levels.WARN) + end + end, + }) + else + termui.open_float_term({ + argv = argv, + title = title, + border = 'rounded', + width = 0.6, + height = 0.6, + cwd = root, + on_exit = function(code) + if code ~= 0 then + util.notify(('cursor-agent exited with code %d'):format(code), vim.log.levels.WARN) + end + end, + }) + end end -- Toggle a long-lived cursor-agent terminal at project root function M.toggle_terminal() local st = M._term_state + local cfg = config.get() -- If window is open, close it (toggle off) if st.win and vim.api.nvim_win_is_valid(st.win) then @@ -104,34 +120,59 @@ function M.toggle_terminal() -- If we have a valid buffer with a live job, just reopen a window for it if st.bufnr and vim.api.nvim_buf_is_valid(st.bufnr) and job_is_alive(st.job_id) then - st.win = termui.open_float_win_for_buf(st.bufnr, { - title = 'Cursor Agent', - border = 'rounded', - width = 0.6, - height = 0.6, - }) + if cfg.window_mode == "attached" then + st.win = termui.open_split_win_for_buf(st.bufnr, { + position = cfg.position, + width = cfg.width, + }) + else + st.win = termui.open_float_win_for_buf(st.bufnr, { + title = 'Cursor Agent', + border = 'rounded', + width = 0.6, + height = 0.6, + }) + end return st.bufnr, st.win end -- Otherwise spawn a fresh terminal - local cfg = config.get() local argv = util.concat_argv(util.to_argv(cfg.cmd), cfg.args) local root = util.get_project_root() - local bufnr, win, job_id = termui.open_float_term({ - argv = argv, - title = 'Cursor Agent', - border = 'rounded', - width = 0.6, - height = 0.6, - cwd = root, - on_exit = function(code) - -- Clear stored job id when it exits - if M._term_state then M._term_state.job_id = nil end - if code ~= 0 then - util.notify(('cursor-agent exited with code %d'):format(code), vim.log.levels.WARN) - end - end, - }) + local bufnr, win, job_id + + if cfg.window_mode == "attached" then + bufnr, win, job_id = termui.open_split_term({ + argv = argv, + position = cfg.position, + width = cfg.width, + cwd = root, + on_exit = function(code) + -- Clear stored job id when it exits + if M._term_state then M._term_state.job_id = nil end + if code ~= 0 then + util.notify(('cursor-agent exited with code %d'):format(code), vim.log.levels.WARN) + end + end, + }) + else + bufnr, win, job_id = termui.open_float_term({ + argv = argv, + title = 'Cursor Agent', + border = 'rounded', + width = 0.6, + height = 0.6, + cwd = root, + on_exit = function(code) + -- Clear stored job id when it exits + if M._term_state then M._term_state.job_id = nil end + if code ~= 0 then + util.notify(('cursor-agent exited with code %d'):format(code), vim.log.levels.WARN) + end + end, + }) + end + st.bufnr, st.win, st.job_id = bufnr, win, job_id return bufnr, win end diff --git a/lua/cursor-agent/ui/float.lua b/lua/cursor-agent/ui/float.lua index 9ad794d..be675e9 100644 --- a/lua/cursor-agent/ui/float.lua +++ b/lua/cursor-agent/ui/float.lua @@ -10,6 +10,34 @@ local function resolve_size(value, total) return math.floor(total * 0.6) end +---Open a split window for displaying output +---@param opts table +---@field position string|nil "left" or "right" (defaults to "right") +---@field width number|nil Width in columns or 0-1 float for percentage (defaults to 0.2) +---@return integer bufnr, integer win +function M.open_split(opts) + opts = opts or {} + local position = opts.position or "right" + local width = resolve_size(opts.width or 0.2, vim.o.columns) + + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_option(bufnr, "bufhidden", "wipe") + vim.api.nvim_buf_set_option(bufnr, "filetype", "cursor-agent-output") + + -- Create the split window + local split_cmd = position == "left" and "leftabove vertical " or "rightbelow vertical " + split_cmd = split_cmd .. width .. "split" + + vim.cmd(split_cmd) + local win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(win, bufnr) + + vim.wo[win].wrap = true + vim.wo[win].cursorline = false + + return bufnr, win +end + function M.open_float(opts) opts = opts or {} local width = resolve_size(opts.width or 0.5, vim.o.columns) diff --git a/lua/cursor-agent/ui/term.lua b/lua/cursor-agent/ui/term.lua index 8253678..18bbf65 100644 --- a/lua/cursor-agent/ui/term.lua +++ b/lua/cursor-agent/ui/term.lua @@ -10,6 +10,110 @@ local function resolve_size(value, total) return math.floor(total * 0.6) end +---Open a split (attached) terminal window and run the provided argv command +---@param opts table +---@field argv string[]|string Command to execute (argv table preferred) +---@field title string|nil Window title (not used in split mode) +---@field position string|nil "left" or "right" (defaults to "right") +---@field width number|nil Width in columns or 0-1 float for percentage (defaults to 0.2) +---@field on_exit fun(code: integer)|nil Optional on-exit callback +---@field cwd string|nil Working directory for the terminal process +---@return integer bufnr, integer win, integer job_id +function M.open_split_term(opts) + opts = opts or {} + local position = opts.position or "right" + local width = resolve_size(opts.width or 0.2, vim.o.columns) + + local bufnr = vim.api.nvim_create_buf(false, true) + -- Keep the terminal buffer around when the window closes so it can be reused + vim.api.nvim_buf_set_option(bufnr, "bufhidden", "hide") + + -- Create the split window + local split_cmd = position == "left" and "leftabove vertical " or "rightbelow vertical " + split_cmd = split_cmd .. width .. "split" + + vim.cmd(split_cmd) + local win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(win, bufnr) + + vim.wo[win].wrap = true + vim.wo[win].cursorline = false + vim.wo[win].number = false + vim.wo[win].relativenumber = false + vim.wo[win].signcolumn = "no" + + local argv = opts.argv + + -- Validate cwd. Fall back to current working directory when invalid + local function resolve_cwd(cwd) + if type(cwd) ~= 'string' or cwd == '' then return vim.fn.getcwd() end + local uv = vim.uv or vim.loop + local stat = uv.fs_stat(cwd) + if stat and stat.type == 'directory' then return cwd end + return vim.fn.getcwd() + end + + local job_id = vim.fn.termopen(argv, { + cwd = resolve_cwd(opts.cwd), + on_exit = function(_, code) + if type(opts.on_exit) == "function" then + pcall(opts.on_exit, code) + end + end, + }) + + pcall(vim.keymap.set, 'n', 'q', function() + M.close(win) + end, { buffer = bufnr, nowait = true, silent = true }) + + -- Jump to bottom and enter terminal-mode for immediate typing + local ok_lines, line_count = pcall(vim.api.nvim_buf_line_count, bufnr) + if ok_lines then pcall(vim.api.nvim_win_set_cursor, win, { line_count, 0 }) end + vim.schedule(function() + pcall(vim.cmd, 'startinsert') + end) + + return bufnr, win, job_id +end + +---Open a split window for an existing buffer (no new job is started) +---@param bufnr integer Existing buffer number (e.g. a terminal buffer) +---@param opts table|nil Same window options as open_split_term (position/width) +---@return integer win +function M.open_split_win_for_buf(bufnr, opts) + opts = opts or {} + local position = opts.position or "right" + local width = resolve_size(opts.width or 0.2, vim.o.columns) + + -- Create the split window + local split_cmd = position == "left" and "leftabove vertical " or "rightbelow vertical " + split_cmd = split_cmd .. width .. "split" + + vim.cmd(split_cmd) + local win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_buf(win, bufnr) + + vim.wo[win].wrap = true + vim.wo[win].cursorline = false + vim.wo[win].number = false + vim.wo[win].relativenumber = false + vim.wo[win].signcolumn = "no" + + -- Ensure the convenience close mapping exists on this buffer + pcall(vim.keymap.set, 'n', 'q', function() + M.close(win) + end, { buffer = bufnr, nowait = true, silent = true }) + + -- Jump to bottom and enter terminal-mode for immediate typing + local ok_lines, line_count = pcall(vim.api.nvim_buf_line_count, bufnr) + if ok_lines then pcall(vim.api.nvim_win_set_cursor, win, { line_count, 0 }) end + vim.schedule(function() + pcall(vim.cmd, 'startinsert') + end) + + return win +end + ---Open a floating terminal window and run the provided argv command ---@param opts table ---@field argv string[]|string Command to execute (argv table preferred)