diff --git a/README.md b/README.md index 5ab0c93..784771d 100644 --- a/README.md +++ b/README.md @@ -204,12 +204,42 @@ require("wiremux").send({ Each item in the picker can have: -| Field | What it does | Example | -| --------- | ------------------------------- | ------------------------------------------------ | -| `value` | **(Required)** The text to send | `"Explain {file}"` | -| `label` | Display name in the picker | `"Explain file"` | -| `submit` | Auto-press Enter after sending | `true` (useful for commands) | -| `visible` | Show/hide this item dynamically | `function() return vim.bo.filetype == "lua" end` | +| Field | What it does | Example | +| ----------- | ------------------------------------- | ------------------------------------------------ | +| `value` | **(Required)** The text to send | `"Explain {file}"` | +| `label` | Display name in the picker | `"Explain file"` | +| `submit` | Auto-press Enter after sending | `true` (useful for commands) | +| `visible` | Show/hide this item dynamically | `function() return vim.bo.filetype == "lua" end` | +| `pre_keys` | Keystrokes to send before pasting | `"C-c"`, `{"C-c", "i"}` | +| `post_keys` | Keystrokes to send after pasting | `"Escape"`, `{"Escape", "Enter"}` | + +### Sending Keystrokes Before/After + +Some TUI apps need keystrokes sent before/after the pasted text — for example, `C-c` to cancel any in-progress input, or `Escape` to return to a neutral state after pasting: + +```lua +-- Cancel current input before pasting, return to normal state after +require("wiremux").send({ + value = "my text", + pre_keys = { "C-c" }, + post_keys = { "Escape" }, +}) + +-- Vim-mode editors: enter insert mode before pasting, Escape after +require("wiremux").send({ + value = "my text", + pre_keys = { "i" }, + post_keys = { "Escape" }, +}) + +-- Per-call opts: all items in this keymap use the same keys +require("wiremux").send({ + { label = "Explain", value = "Explain {this}" }, + { label = "Review", value = "Review {changes}" }, +}, { pre_keys = { "i" }, target = "claude" }) +``` + +Item-level `pre_keys`/`post_keys` override opts-level when both are set. ## Placeholders diff --git a/doc/wiremux.txt b/doc/wiremux.txt index 9042b5d..fda86ac 100644 --- a/doc/wiremux.txt +++ b/doc/wiremux.txt @@ -100,6 +100,8 @@ SendItem fields ~ | title | string (optional) | Custom tmux window / zellij tab name | | submit | boolean (optional) | Auto-submit after sending | | visible | boolean or function | Show/hide item based on condition | +| pre_keys | string or string[] | Keystrokes to send before pasting | +| post_keys | string or string[] | Keystrokes to send after pasting | Commands with auto-submit: >lua @@ -128,6 +130,34 @@ Conditional visibility: }) < +Sending keystrokes before/after ~ + *wiremux-howto-pre-post-keys* + +Some TUI apps need keystrokes sent before/after the pasted text. For example, +`C-c` to cancel in-progress input, or `Escape` to return to a neutral state: +>lua + -- Cancel current input before pasting, return to normal state after + require("wiremux").send({ + value = "my text", + pre_keys = { "C-c" }, + post_keys = { "Escape" }, + }) + + -- Vim-mode editors: enter insert mode before pasting, Escape after + require("wiremux").send({ + value = "my text", + pre_keys = { "i" }, + post_keys = { "Escape" }, + }) + + -- Per-call opts (fallback for all items) + require("wiremux").send({ + { label = "Explain", value = "Explain {this}" }, + { label = "Review", value = "Review {changes}" }, + }, { pre_keys = { "i" }, target = "claude" }) +< +Item-level `pre_keys`/`post_keys` override opts-level when both are set. + Dynamic labels for targets ~ You can use a function for `label` to create dynamic display names: diff --git a/lua/wiremux/action/send.lua b/lua/wiremux/action/send.lua index e7f819c..19b80f4 100644 --- a/lua/wiremux/action/send.lua +++ b/lua/wiremux/action/send.lua @@ -6,6 +6,8 @@ local M = {} ---@field submit? boolean Auto-submit after sending (default: false) ---@field visible? boolean|fun(): boolean Show this item in picker (default: true) ---@field title? string Custom tmux window / zellij tab name when creating +---@field pre_keys? string|string[] Keystrokes to send before pasting (e.g. {"C-c"}, {"i"}) +---@field post_keys? string|string[] Keystrokes to send after pasting (e.g. {"Escape"}) ---Check if item should be visible ---@param item wiremux.action.SendItem @@ -50,12 +52,20 @@ local function build_picker_items(items) return picker_items end +---Append "Enter" to post_keys when submit is enabled +---@param post_keys? string|string[] +---@return string[] +local function append_submit(post_keys) + local keys = type(post_keys) == "table" and { unpack(post_keys) } or (post_keys and { post_keys } or {}) + table.insert(keys, "Enter") + return keys +end + ---Execute the send action with expanded text ---@param expanded string The text with placeholders expanded ---@param opts wiremux.config.ActionConfig ----@param submit boolean Whether to auto-submit ----@param title? string Custom tmux window / zellij tab name when creating -local function do_send(expanded, opts, submit, title) +---@param send_opts { title?: string, pre_keys?: string|string[], post_keys?: string|string[] } +local function do_send(expanded, opts, send_opts) local config = require("wiremux.config") local action = require("wiremux.core.action") local backend = require("wiremux.backend").get() @@ -65,6 +75,7 @@ local function do_send(expanded, opts, submit, title) end local focus = opts.focus or config.opts.actions.send.focus + local backend_opts = vim.tbl_extend("force", send_opts, { focus = focus }) action.run({ prompt = "Send to", @@ -74,18 +85,18 @@ local function do_send(expanded, opts, submit, title) target = opts.target, }, { on_targets = function(targets, state) - backend.send(expanded, targets, { focus = focus, submit = submit }, state) + backend.send(expanded, targets, backend_opts, state) end, on_definition = function(name, def, state) local has_own_cmd = def.cmd ~= nil local modified_def = vim.tbl_extend("force", {}, def, { cmd = def.cmd or expanded, - title = title, + title = send_opts.title, }) local inst = backend.create(name, modified_def, state) if inst and has_own_cmd then backend.wait_for_ready(inst, { timeout_ms = def.startup_timeout }, function() - backend.send(expanded, { inst }, { focus = focus, submit = submit }, state) + backend.send(expanded, { inst }, backend_opts, state) end) end end, @@ -110,7 +121,18 @@ local function send_single_item(item, opts) submit = opts.submit or config.opts.actions.send.submit end - do_send(expanded, opts, submit, item.title) + local pre_keys = item.pre_keys or opts.pre_keys + local post_keys = item.post_keys or opts.post_keys + + if submit then + post_keys = append_submit(post_keys) + end + + do_send(expanded, opts, { + title = item.title, + pre_keys = pre_keys, + post_keys = post_keys, + }) end ---Send from send library (picker) @@ -154,7 +176,18 @@ local function send_from_library(items, opts) submit = opts.submit or config.opts.actions.send.submit end - do_send(expanded[item] or item.value, opts, submit, item.title) + local pre_keys = item.pre_keys or opts.pre_keys + local post_keys = item.post_keys or opts.post_keys + + if submit then + post_keys = append_submit(post_keys) + end + + do_send(expanded[item] or item.value, opts, { + title = item.title, + pre_keys = pre_keys, + post_keys = post_keys, + }) end) end diff --git a/lua/wiremux/backend/tmux/operation.lua b/lua/wiremux/backend/tmux/operation.lua index e187343..8c9a9b9 100644 --- a/lua/wiremux/backend/tmux/operation.lua +++ b/lua/wiremux/backend/tmux/operation.lua @@ -14,11 +14,12 @@ local function get_focus_cmds(target) end ---@param targets wiremux.Instance[] -local function submit(targets) +---@param opts { post_keys: string|string[] } +local function _send_deferred(targets, opts) vim.defer_fn(function() local batch = {} for _, t in ipairs(targets) do - table.insert(batch, action.send_keys(t.id, "Enter")) + table.insert(batch, action.send_keys(t.id, opts.post_keys)) end client.execute(batch) end, 300) @@ -26,7 +27,7 @@ end ---@param text string ---@param targets wiremux.Instance[] ----@param opts? { focus?: boolean, submit?: boolean } +---@param opts? { focus?: boolean, pre_keys?: string|string[], post_keys?: string|string[] } ---@param st wiremux.State function M.send(text, targets, opts, st) opts = opts or {} @@ -35,6 +36,9 @@ function M.send(text, targets, opts, st) local batch = { action.load_buffer(BUFFER_NAME) } for _, t in ipairs(targets) do + if opts.pre_keys then + table.insert(batch, action.send_keys(t.id, opts.pre_keys)) + end table.insert(batch, action.paste_buffer(BUFFER_NAME, t.id)) end @@ -58,8 +62,8 @@ function M.send(text, targets, opts, st) return end - if opts.submit then - submit(targets) + if opts.post_keys then + _send_deferred(targets, opts) end notify.debug("send: sent to %d targets", #targets) diff --git a/lua/wiremux/config.lua b/lua/wiremux/config.lua index 0800e35..43ea005 100644 --- a/lua/wiremux/config.lua +++ b/lua/wiremux/config.lua @@ -37,6 +37,8 @@ local M = {} ---@field submit? boolean ---@field filter? wiremux.config.FilterConfig ---@field target? string Target definition name. Sends directly to matching instance, auto-creates if none exist. +---@field pre_keys? string|string[] Keystrokes to send before action (e.g. {"C-c"}, {"i"}) +---@field post_keys? string|string[] Keystrokes to send after action (e.g. {"Escape"}) ---@class wiremux.target.definition ---@field cmd? string Command to run in the new pane/window diff --git a/tests/operation_spec.lua b/tests/operation_spec.lua index 3045da1..9c1851c 100644 --- a/tests/operation_spec.lua +++ b/tests/operation_spec.lua @@ -155,6 +155,170 @@ describe("tmux operations", function() assert.is_false(found_last_used) end) + it("sends pre_keys before paste-buffer", function() + local batch_cmds + mocks.client.execute = function(batch, _) + batch_cmds = batch + return "ok" + end + + local targets = { { id = "%1", kind = "pane", target = "test" } } + local st = { instances = {}, last_used_target_id = nil } + + mocks.operation.send("text", targets, { pre_keys = { "i" } }, st) + + local pre_keys_idx, paste_idx + for i, cmd in ipairs(batch_cmds) do + if cmd[1] == "send-keys" and vim.deep_equal(cmd, { "send-keys", "-t", "%1", "i" }) then + pre_keys_idx = i + elseif cmd[1] == "paste-buffer" then + paste_idx = i + end + end + + assert.is_not_nil(pre_keys_idx) + assert.is_not_nil(paste_idx) + assert.is_true(pre_keys_idx < paste_idx) + end) + + it("sends post_keys in deferred batch after paste-buffer", function() + local executed_batches = {} + mocks.client.execute = function(batch, _) + table.insert(executed_batches, batch) + return "ok" + end + + local deferred_fn + local original_defer_fn = vim.defer_fn + vim.defer_fn = function(fn, _) + deferred_fn = fn + end + + local targets = { { id = "%1", kind = "pane", target = "test" } } + local st = { instances = {}, last_used_target_id = nil } + + mocks.operation.send("text", targets, { post_keys = { "Escape" } }, st) + + -- post_keys should NOT be in main batch + local main_batch = executed_batches[1] + for _, cmd in ipairs(main_batch) do + if cmd[1] == "send-keys" and vim.tbl_contains(cmd, "Escape") then + error("post_keys should not be in main batch") + end + end + + -- post_keys should be in deferred batch + assert.is_function(deferred_fn) + deferred_fn() + assert.are.equal(2, #executed_batches) + assert.are.equal("send-keys", executed_batches[2][1][1]) + assert.is_true(vim.tbl_contains(executed_batches[2][1], "Escape")) + + vim.defer_fn = original_defer_fn + end) + + it("sends pre_keys in main batch, post_keys in deferred batch", function() + local executed_batches = {} + mocks.client.execute = function(batch, _) + table.insert(executed_batches, batch) + return "ok" + end + + local deferred_fn + local original_defer_fn = vim.defer_fn + vim.defer_fn = function(fn, _) + deferred_fn = fn + end + + local targets = { { id = "%1", kind = "pane", target = "test" } } + local st = { instances = {}, last_used_target_id = nil } + + mocks.operation.send("text", targets, { + pre_keys = { "i" }, + post_keys = { "Escape" }, + }, st) + + -- Main batch: pre_keys + paste, no post_keys + local main_batch = executed_batches[1] + local pre_idx, paste_idx + for i, cmd in ipairs(main_batch) do + if cmd[1] == "send-keys" and vim.tbl_contains(cmd, "i") then + pre_idx = i + elseif cmd[1] == "paste-buffer" then + paste_idx = i + elseif cmd[1] == "send-keys" and vim.tbl_contains(cmd, "Escape") then + error("post_keys should not be in main batch") + end + end + assert.is_not_nil(pre_idx) + assert.is_not_nil(paste_idx) + assert.is_true(pre_idx < paste_idx) + + -- Deferred batch: post_keys + assert.is_function(deferred_fn) + deferred_fn() + assert.are.equal(2, #executed_batches) + assert.is_true(vim.tbl_contains(executed_batches[2][1], "Escape")) + + vim.defer_fn = original_defer_fn + end) + + it("sends pre_keys/post_keys for each target in multi-target send", function() + local executed_batches = {} + mocks.client.execute = function(batch, _) + table.insert(executed_batches, batch) + return "ok" + end + + local deferred_fn + local original_defer_fn = vim.defer_fn + vim.defer_fn = function(fn, _) + deferred_fn = fn + end + + local targets = { + { id = "%1", kind = "pane", target = "t1" }, + { id = "%2", kind = "pane", target = "t2" }, + } + local st = { instances = {}, last_used_target_id = nil } + + mocks.operation.send("text", targets, { + pre_keys = { "i" }, + post_keys = { "Escape" }, + }, st) + + -- Main batch: pre_keys + paste for each target + local main_batch = executed_batches[1] + local pre_count = 0 + local paste_count = 0 + + for _, cmd in ipairs(main_batch) do + if cmd[1] == "send-keys" and vim.tbl_contains(cmd, "i") then + pre_count = pre_count + 1 + elseif cmd[1] == "paste-buffer" then + paste_count = paste_count + 1 + end + end + + assert.are.equal(2, pre_count) + assert.are.equal(2, paste_count) + + -- Deferred batch: post_keys for each target + assert.is_function(deferred_fn) + deferred_fn() + assert.are.equal(2, #executed_batches) + + local post_count = 0 + for _, cmd in ipairs(executed_batches[2]) do + if cmd[1] == "send-keys" and vim.tbl_contains(cmd, "Escape") then + post_count = post_count + 1 + end + end + assert.are.equal(2, post_count) + + vim.defer_fn = original_defer_fn + end) + it("respects submit option", function() local executed_batches = {} mocks.client.execute = function(batch, _) @@ -171,7 +335,7 @@ describe("tmux operations", function() local targets = { { id = "%1", kind = "pane", target = "test" } } local st = { instances = {}, last_used_target_id = nil } - mocks.operation.send("text", targets, { submit = true }, st) + mocks.operation.send("text", targets, { post_keys = { "Enter" } }, st) assert.are.equal(1, #executed_batches) assert.is_function(deferred_submit) @@ -181,7 +345,7 @@ describe("tmux operations", function() executed_batches = {} deferred_submit = nil - mocks.operation.send("text", targets, { submit = false }, st) + mocks.operation.send("text", targets, {}, st) assert.are.equal(1, #executed_batches) assert.is_nil(deferred_submit) diff --git a/tests/send_items_spec.lua b/tests/send_items_spec.lua index 965366e..fe326c6 100644 --- a/tests/send_items_spec.lua +++ b/tests/send_items_spec.lua @@ -62,8 +62,19 @@ describe("send single item", function() -- Test visible returns true mocks.send.send({ - { value = "shown", visible = function() fn_called = true return true end }, - { value = "hidden", visible = function() return false end }, + { + value = "shown", + visible = function() + fn_called = true + return true + end, + }, + { + value = "hidden", + visible = function() + return false + end, + }, }) assert.is_true(fn_called) @@ -89,7 +100,8 @@ describe("send single item", function() submit = true, }) - assert.is_true(send_opts.submit) + assert.is_nil(send_opts.submit) + assert.are.same({ "Enter" }, send_opts.post_keys) end) it("falls back to config submit option", function() @@ -108,7 +120,8 @@ describe("send single item", function() mocks.config.opts.actions.send.submit = true mocks.send.send({ value = "npm test" }) - assert.is_true(send_opts.submit) + assert.is_nil(send_opts.submit) + assert.are.same({ "Enter" }, send_opts.post_keys) end) end) diff --git a/tests/send_options_spec.lua b/tests/send_options_spec.lua index 3c3f7cf..8bfc79a 100644 --- a/tests/send_options_spec.lua +++ b/tests/send_options_spec.lua @@ -135,6 +135,103 @@ describe("send with options", function() end) end) +describe("send with pre_keys/post_keys", function() + local mocks + + before_each(function() + mocks = helpers.setup() + end) + + it("passes pre_keys from SendItem to backend.send opts", function() + local send_opts + + mocks.backend.send = function(text, targets, opts, state) + send_opts = opts + end + + mocks.action.run = function(opts, callbacks) + callbacks.on_targets({ { id = "%1", kind = "pane", target = "test" } }, {}) + end + + mocks.send.send({ value = "text", pre_keys = { "i" } }) + + assert.are.same({ "i" }, send_opts.pre_keys) + end) + + it("passes post_keys from SendItem to backend.send opts", function() + local send_opts + + mocks.backend.send = function(text, targets, opts, state) + send_opts = opts + end + + mocks.action.run = function(opts, callbacks) + callbacks.on_targets({ { id = "%1", kind = "pane", target = "test" } }, {}) + end + + mocks.send.send({ value = "text", post_keys = { "Escape" } }) + + assert.are.same({ "Escape" }, send_opts.post_keys) + end) + + it("falls back to opts.pre_keys when item has none", function() + local send_opts + + mocks.backend.send = function(text, targets, opts, state) + send_opts = opts + end + + mocks.action.run = function(opts, callbacks) + callbacks.on_targets({ { id = "%1", kind = "pane", target = "test" } }, {}) + end + + mocks.send.send({ value = "text" }, { pre_keys = { "i" } }) + + assert.are.same({ "i" }, send_opts.pre_keys) + end) + + it("item pre_keys overrides opts.pre_keys", function() + local send_opts + + mocks.backend.send = function(text, targets, opts, state) + send_opts = opts + end + + mocks.action.run = function(opts, callbacks) + callbacks.on_targets({ { id = "%1", kind = "pane", target = "test" } }, {}) + end + + mocks.send.send({ value = "text", pre_keys = { "a" } }, { pre_keys = { "i" } }) + + assert.are.same({ "a" }, send_opts.pre_keys) + end) + + it("passes pre_keys/post_keys from library item through picker", function() + local send_opts + + mocks.backend.send = function(text, targets, opts, state) + send_opts = opts + end + + mocks.action.run = function(opts, callbacks) + callbacks.on_targets({ { id = "%1", kind = "pane", target = "test" } }, {}) + end + + local items = { + { value = "text", label = "Test", pre_keys = { "i" }, post_keys = { "Escape" } }, + } + + mocks.picker.select = function(picker_items, opts, on_choice) + on_choice(picker_items[1]) + end + + mocks.send.send(items) + + assert.are.same({ "i" }, send_opts.pre_keys) + assert.are.same({ "Escape" }, send_opts.post_keys) + end) +end) + describe("context expansion", function() local mocks