From 8207855e3181cb282634e2ba4fb39eff51ca7581 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 8 Jul 2026 18:24:08 +0200 Subject: [PATCH 1/2] feat(cli): forward modified special keys (e.g. ) to the agent as CSI-u. Enable with cli.win.csiu --- lua/sidekick/cli/csiu.lua | 38 ++++++++++++++++ lua/sidekick/config.lua | 12 +++++ tests/csiu_spec.lua | 96 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 lua/sidekick/cli/csiu.lua create mode 100644 tests/csiu_spec.lua diff --git a/lua/sidekick/cli/csiu.lua b/lua/sidekick/cli/csiu.lua new file mode 100644 index 00000000..2bc20ef5 --- /dev/null +++ b/lua/sidekick/cli/csiu.lua @@ -0,0 +1,38 @@ +-- CSI-u key forwarding. +-- Neovim collapses modified special keys (e.g. ``) 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 +function M.defaults() + return { + shift_cr = key("", 13, mod.shift), + alt_cr = key("", 13, mod.alt), + ctrl_cr = key("", 13, mod.ctrl), + ctrl_shift_cr = key("", 13, mod.ctrl + mod.shift), + alt_shift_cr = key("", 13, mod.alt + mod.shift), + ctrl_alt_cr = key("", 13, mod.ctrl + mod.alt), + ctrl_space = key("", 32, mod.ctrl), + } +end + +return M diff --git a/lua/sidekick/config.lua b/lua/sidekick/config.lua index d30e92e8..8940f166 100644 --- a/lua/sidekick/config.lua +++ b/lua/sidekick/config.lua @@ -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. ``) 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 @@ -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, { diff --git a/tests/csiu_spec.lua b/tests/csiu_spec.lua new file mode 100644 index 00000000..feab2d44 --- /dev/null +++ b/tests/csiu_spec.lua @@ -0,0 +1,96 @@ +---@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", "", "\27[13;2u" }, + { "alt_cr", "", "\27[13;3u" }, + { "ctrl_cr", "", "\27[13;5u" }, + { "ctrl_shift_cr", "", "\27[13;6u" }, + { "alt_shift_cr", "", "\27[13;4u" }, + { "ctrl_alt_cr", "", "\27[13;7u" }, + { "ctrl_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("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) From e4f43d4f44da3e01d9a82b6c78ca6d64bf841ae9 Mon Sep 17 00:00:00 2001 From: S1M0N38 Date: Wed, 8 Jul 2026 18:24:29 +0200 Subject: [PATCH 2/2] feat(mux): pass CSI-u through tmux when cli.win.csiu is on --- lua/sidekick/cli/session/tmux.lua | 14 ++++++++++++++ tests/csiu_spec.lua | 32 +++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/lua/sidekick/cli/session/tmux.lua b/lua/sidekick/cli/session/tmux.lua index 95dbbb48..6de3cbc6 100644 --- a/lua/sidekick/cli/session/tmux.lua +++ b/lua/sidekick/cli/session/tmux.lua @@ -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 } @@ -82,6 +83,19 @@ function M:add_cmd(ret) vim.list_extend(ret, self.tool.cmd) end +--- Build `; set-option ` 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 {} diff --git a/tests/csiu_spec.lua b/tests/csiu_spec.lua index feab2d44..d20ae55e 100644 --- a/tests/csiu_spec.lua +++ b/tests/csiu_spec.lua @@ -57,6 +57,38 @@ describe("csiu.defaults", function() 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.