Skip to content
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
38 changes: 38 additions & 0 deletions lua/sidekick/cli/csiu.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
-- CSI-u key forwarding.
-- Neovim collapses modified special keys (e.g. `<S-CR>`) when forwarding to a
-- terminal job; these keymaps send the raw CSI-u bytes straight to the job.
-- Enable with `opts.cli.win.csiu = true` (the tmux options are applied automatically).

local M = {}

-- CSI-u modifier bitmask (encoded number = 1 + bitmask)
local mod = { shift = 1, alt = 2, ctrl = 4 }

local function key(lhs, code, m)
local bytes = ("\27[%d;%du"):format(code, 1 + (m or 0))
return {
lhs,
function(self)
if self:is_running() then
vim.api.nvim_chan_send(self.job, bytes)
end
end,
mode = "t",
}
end

--- Default CSI-u keymaps, merged into `opts.cli.win.keys` when `cli.win.csiu` is on.
---@return table<string, sidekick.cli.Keymap>
function M.defaults()
return {
shift_cr = key("<S-CR>", 13, mod.shift),
alt_cr = key("<A-CR>", 13, mod.alt),
ctrl_cr = key("<C-CR>", 13, mod.ctrl),
ctrl_shift_cr = key("<C-S-CR>", 13, mod.ctrl + mod.shift),
alt_shift_cr = key("<A-S-CR>", 13, mod.alt + mod.shift),
ctrl_alt_cr = key("<C-A-CR>", 13, mod.ctrl + mod.alt),
ctrl_space = key("<C-Space>", 32, mod.ctrl),
}
end

return M
14 changes: 14 additions & 0 deletions lua/sidekick/cli/session/tmux.lua
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function M:start()
self:add_cmd(cmd)
vim.list_extend(cmd, { ";", "set-option", "status", "off" })
vim.list_extend(cmd, { ";", "set-option", "detach-on-destroy", "on" })
vim.list_extend(cmd, M.options())
return { cmd = cmd }
elseif Config.cli.mux.create == "window" then
local cmd = { "tmux", "new-window", "-dP", "-c", self.cwd, "-F", PANE_FORMAT }
Expand Down Expand Up @@ -82,6 +83,19 @@ function M:add_cmd(ret)
vim.list_extend(ret, self.tool.cmd)
end

--- Build `; set-option <name> <value>` segments for the mux session.
--- When `opts.cli.win.csiu` is on, the tmux options required to pass CSI-u
--- through (`extended-keys`, `extended-keys-format`) are added automatically.
---@return string[]
function M.options()
local ret = {} ---@type string[]
if Config.cli.win.csiu then
vim.list_extend(ret, { ";", "set-option", "extended-keys", "on" })
vim.list_extend(ret, { ";", "set-option", "extended-keys-format", "csi-u" })
end
return ret
end

---@param opts? { cmd?:string[], notify?:boolean }
function M.panes(opts)
opts = opts or {}
Expand Down
12 changes: 12 additions & 0 deletions lua/sidekick/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,13 @@ local defaults = {
cli = {
watch = true, -- notify Neovim of file changes done by AI CLI tools
---@class sidekick.win.Opts
---@field csiu? boolean Forward modified keys (e.g. `<S-CR>`) to the agent as CSI-u
win = {
-- When enabled, sidekick injects default CSI-u keymaps (see `lua/sidekick/cli/csiu.lua`)
-- so modified special keys reach the agent instead of being collapsed by Neovim.
-- With the tmux backend, the options required to pass CSI-u through
-- (`extended-keys`, `extended-keys-format`) are applied automatically.
csiu = false,
--- This is run when a new terminal is created, before starting it.
--- Here you can change window options `terminal.opts`.
---@param terminal sidekick.cli.Terminal
Expand Down Expand Up @@ -186,6 +192,12 @@ end
function M.setup(opts)
config = vim.tbl_deep_extend("force", {}, vim.deepcopy(defaults), opts or {})

-- When `cli.win.csiu` is on, inject the default CSI-u keymaps into `cli.win.keys`.
-- User keys (and `= false` to disable) still win, since they're merged on top.
if config.cli.win.csiu then
config.cli.win.keys = vim.tbl_extend("force", require("sidekick.cli.csiu").defaults(), config.cli.win.keys)
end

vim.api.nvim_create_user_command("Sidekick", function(args)
require("sidekick.commands").cmd(args)
end, {
Expand Down
128 changes: 128 additions & 0 deletions tests/csiu_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
---@module 'luassert'

local Config = require("sidekick.config")
local csiu = require("sidekick.cli.csiu")

describe("csiu.defaults", function()
local sent, orig_chan_send

before_each(function()
sent = {}
orig_chan_send = vim.api.nvim_chan_send
vim.api.nvim_chan_send = function(_, data)
table.insert(sent, data)
end
end)

after_each(function()
vim.api.nvim_chan_send = orig_chan_send
end)

-- name, lhs, expected CSI-u bytes (number = 1 + modifier bitmask)
local cases = {
{ "shift_cr", "<S-CR>", "\27[13;2u" },
{ "alt_cr", "<A-CR>", "\27[13;3u" },
{ "ctrl_cr", "<C-CR>", "\27[13;5u" },
{ "ctrl_shift_cr", "<C-S-CR>", "\27[13;6u" },
{ "alt_shift_cr", "<A-S-CR>", "\27[13;4u" },
{ "ctrl_alt_cr", "<C-A-CR>", "\27[13;7u" },
{ "ctrl_space", "<C-Space>", "\27[32;5u" },
}

for _, c in ipairs(cases) do
it(("%s is a terminal keymap sending %q"):format(c[1], c[2]), function()
local km = csiu.defaults()[c[1]]
assert.is_not_nil(km)
assert.are.equal(c[2], km[1])
assert.are.equal("t", km.mode)
assert.are.equal("function", type(km[2]))
km[2]({
is_running = function()
return true
end,
job = 1,
})
assert.are.equal(c[3], sent[1])
end)
end

it("does nothing when the job is not running", function()
csiu.defaults().shift_cr[2]({
is_running = function()
return false
end,
job = 1,
})
assert.are.same({}, sent)
end)
end)

describe("tmux.options (CSI-u auto-inject)", function()
local tmux = require("sidekick.cli.session.tmux")
local orig_csiu

before_each(function()
orig_csiu = Config.cli.win.csiu
end)

after_each(function()
Config.cli.win.csiu = orig_csiu
end)

local function seg()
return table.concat(tmux.options(), " ")
end
local function has(s, lit)
return string.find(s, lit, 1, true) ~= nil
end

it("injects extended-keys options when win.csiu is on", function()
Config.cli.win.csiu = true
local s = seg()
assert.is_true(has(s, "; set-option extended-keys on"))
assert.is_true(has(s, "; set-option extended-keys-format csi-u"))
end)

it("injects nothing when win.csiu is off", function()
Config.cli.win.csiu = false
assert.are.same({}, tmux.options())
end)
end)

describe("config setup: CSI-u keymap injection", function()
-- Suppress the deferred side effects of Config.setup (autocmds, nes, status)
-- so repeated setup() calls don't accumulate them across the suite.
local orig_schedule

before_each(function()
orig_schedule = vim.schedule
vim.schedule = function() end
end)

after_each(function()
Config.setup({}) -- restore defaults (schedule still suppressed)
vim.schedule = orig_schedule
end)

it("injects csiu keymaps into cli.win.keys when csiu is on", function()
Config.setup({ cli = { win = { csiu = true } } })
local keys = Config.cli.win.keys
assert.is_not_nil(keys.shift_cr)
assert.is_not_nil(keys.ctrl_cr)
assert.is_not_nil(keys.ctrl_shift_cr)
assert.is_not_nil(keys.alt_cr)
end)

it("does not inject when csiu is off", function()
Config.setup({ cli = { win = { csiu = false } } })
assert.is_nil(Config.cli.win.keys.shift_cr)
end)

it("user keys override and can disable individual csiu keymaps", function()
Config.setup({ cli = { win = { csiu = true, keys = { prompt = false, ctrl_cr = false } } } })
local keys = Config.cli.win.keys
assert.is_false(keys.prompt) -- user override preserved
assert.is_false(keys.ctrl_cr) -- user disabled this csiu key
assert.is_not_nil(keys.shift_cr) -- other csiu keys still present
end)
end)
Loading