From 10a40edb52689c0b768f0075d9c52930c030e368 Mon Sep 17 00:00:00 2001 From: jxyyz <132339113+jxyyz@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:50:23 +0100 Subject: [PATCH 1/3] feat: add {input} placeholder support for interactive user input Add a new context/input module that handles {input}, {input:Prompt}, and {input:Prompt:default} placeholders in send templates. These placeholders prompt the user via vim.ui.input() at send time, enabling dynamic values like branch names or arguments. - Add context/input.lua with parse, find, replace, and resolve functions - Integrate input resolution into action/send's send_single_item flow - Skip {input} keys during sync context.expand() to avoid errors --- .gitignore | 2 + lua/wiremux/action/send.lua | 21 ++++++- lua/wiremux/context/init.lua | 1 + lua/wiremux/context/input.lua | 112 ++++++++++++++++++++++++++++++++++ 4 files changed, 133 insertions(+), 3 deletions(-) create mode 100644 lua/wiremux/context/input.lua diff --git a/.gitignore b/.gitignore index b27d4ee..33592d9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,5 @@ /doc/tags foo.* tt.* +tmp_* +.tmp_* diff --git a/lua/wiremux/action/send.lua b/lua/wiremux/action/send.lua index e2f547c..97ee1b0 100644 --- a/lua/wiremux/action/send.lua +++ b/lua/wiremux/action/send.lua @@ -97,11 +97,13 @@ end ---@param opts wiremux.config.ActionConfig local function send_single_item(item, opts) local context = require("wiremux.context") + local input = require("wiremux.context.input") local config = require("wiremux.config") - local ok, expanded = pcall(context.expand, item.value) + -- Expand sync placeholders + local ok, text = pcall(context.expand, item.value) if not ok then - require("wiremux.utils.notify").error(expanded) + require("wiremux.utils.notify").error(text) return end @@ -110,7 +112,20 @@ local function send_single_item(item, opts) submit = opts.submit or config.opts.actions.send.submit end - do_send(expanded, opts, submit, item.title) + -- Handle async {input} placeholders + local keys = input.find(text) + if #keys == 0 then + do_send(text, opts, submit, item.title) + return + end + + input.resolve(keys, function(values) + if not values then + return + end + local resolved = input.replace(text, values) + do_send(resolved, opts, submit, item.title) + end) end ---Send from send library (picker) diff --git a/lua/wiremux/context/init.lua b/lua/wiremux/context/init.lua index cc2d20d..d3c062a 100644 --- a/lua/wiremux/context/init.lua +++ b/lua/wiremux/context/init.lua @@ -54,6 +54,7 @@ function M.expand(text) local cache = {} return ( text:gsub("{([%w_]+)}", function(var) + if var == "input" then return nil end if cache[var] == nil then cache[var] = M.get(var) end diff --git a/lua/wiremux/context/input.lua b/lua/wiremux/context/input.lua new file mode 100644 index 0000000..18b66f1 --- /dev/null +++ b/lua/wiremux/context/input.lua @@ -0,0 +1,112 @@ +local M = {} + +---Parse an input key into prompt and default value +---Key formats: "input", "input:Prompt", "input:Prompt:default" +---@param key string The full key (e.g. "input:Branch:main") +---@return string prompt +---@return string? default +function M.parse(key) + if key == "input" then + return "Input", nil + end + + -- Strip "input:" prefix + local rest = key:sub(#"input:" + 1) + + -- Find first colon for prompt:default split + local colon_pos = rest:find(":", 1, true) + if not colon_pos then + return rest, nil + end + + local prompt = rest:sub(1, colon_pos - 1) + local default = rest:sub(colon_pos + 1) + + return prompt, default +end + +---Find all unique input placeholder keys in text +---Returns keys in order of first appearance +---@param text string +---@return string[] +function M.find(text) + if not text:find("{input", 1, true) then + return {} + end + + local seen = {} + local keys = {} + + -- Match {input...} placeholders — capture everything between { and } + for content in text:gmatch("{(input[^}]*)}") do + -- Reject names like {input_var} or {input2} — only allow bare "input" or "input:..." + if content == "input" or content:sub(1, 6) == "input:" then + if not seen[content] then + seen[content] = true + table.insert(keys, content) + end + end + end + + return keys +end + +---Quick check if text contains any input placeholders +---@param text string +---@return boolean +function M.has_inputs(text) + return #M.find(text) > 0 +end + +---Replace input placeholders in text with resolved values +---Only replaces placeholders whose keys exist in the values table +---@param text string +---@param values table +---@return string +function M.replace(text, values) + return text:gsub("{(input[^}]*)}", function(content) + if content == "input" or content:sub(1, 6) == "input:" then + if values[content] ~= nil then + return values[content] + end + end + -- Leave non-input or unresolved placeholders intact + return "{" .. content .. "}" + end) +end + +---Resolve input placeholders by chaining vim.ui.input() calls +---Calls on_done(values) with a table of key→value, or on_done(nil) if user cancels +---@param keys string[] Unique input keys to resolve +---@param on_done fun(values: table|nil) +function M.resolve(keys, on_done) + local values = {} + local i = 0 + + local function next_input() + i = i + 1 + if i > #keys then + on_done(values) + return + end + + local key = keys[i] + local prompt, default = M.parse(key) + + vim.ui.input({ + prompt = prompt .. ": ", + default = default or "", + }, function(value) + if value == nil then + on_done(nil) + return + end + values[key] = value + next_input() + end) + end + + next_input() +end + +return M From 36b5fadeed431992f8bb8cc46ea0057974ff2dd4 Mon Sep 17 00:00:00 2001 From: jxyyz <132339113+jxyyz@users.noreply.github.com> Date: Tue, 24 Feb 2026 01:14:27 +0100 Subject: [PATCH 2/3] tests: add tests for {input} placeholder support Add unit tests for context.input module (parse, find, has_inputs, replace, resolve) and integration tests for send action with input placeholders. Update helpers_send with context.input mock. --- tests/context_input_spec.lua | 232 +++++++++++++++++++++++++++++++++++ tests/helpers_send.lua | 19 +++ tests/send_input_spec.lua | 165 +++++++++++++++++++++++++ 3 files changed, 416 insertions(+) create mode 100644 tests/context_input_spec.lua create mode 100644 tests/send_input_spec.lua diff --git a/tests/context_input_spec.lua b/tests/context_input_spec.lua new file mode 100644 index 0000000..5e4c17f --- /dev/null +++ b/tests/context_input_spec.lua @@ -0,0 +1,232 @@ +---@module 'luassert' + +describe("context.input", function() + local input + + before_each(function() + package.loaded["wiremux.context.input"] = nil + input = require("wiremux.context.input") + end) + + describe("parse", function() + it("returns default prompt for bare input", function() + local prompt, default = input.parse("input") + assert.are.equal("Input", prompt) + assert.is_nil(default) + end) + + it("returns custom prompt without default", function() + local prompt, default = input.parse("input:Enter branch name") + assert.are.equal("Enter branch name", prompt) + assert.is_nil(default) + end) + + it("returns prompt and default", function() + local prompt, default = input.parse("input:Branch:main") + assert.are.equal("Branch", prompt) + assert.are.equal("main", default) + end) + + it("handles default with colons", function() + local prompt, default = input.parse("input:URL:http://localhost:8080") + assert.are.equal("URL", prompt) + assert.are.equal("http://localhost:8080", default) + end) + end) + + describe("find", function() + it("returns empty for text without input placeholders", function() + local keys = input.find("echo hello {file}") + assert.are.equal(0, #keys) + end) + + it("finds bare {input}", function() + local keys = input.find("echo {input}") + assert.are.equal(1, #keys) + assert.are.equal("input", keys[1]) + end) + + it("finds {input:prompt}", function() + local keys = input.find("git checkout {input:Branch}") + assert.are.equal(1, #keys) + assert.are.equal("input:Branch", keys[1]) + end) + + it("finds {input:prompt:default}", function() + local keys = input.find("git checkout {input:Branch:main}") + assert.are.equal(1, #keys) + assert.are.equal("input:Branch:main", keys[1]) + end) + + it("deduplicates same placeholder", function() + local keys = input.find("{input} and {input}") + assert.are.equal(1, #keys) + assert.are.equal("input", keys[1]) + end) + + it("finds multiple distinct inputs", function() + local keys = input.find("mv {input:Source} {input:Destination}") + assert.are.equal(2, #keys) + assert.are.equal("input:Source", keys[1]) + assert.are.equal("input:Destination", keys[2]) + end) + + it("ignores non-input placeholders like {input_var}", function() + local keys = input.find("{input_var} and {input}") + assert.are.equal(1, #keys) + assert.are.equal("input", keys[1]) + end) + + it("ignores {input2} style names", function() + local keys = input.find("{input2} test") + assert.are.equal(0, #keys) + end) + end) + + describe("has_inputs", function() + it("returns false for plain text", function() + assert.is_false(input.has_inputs("no placeholders")) + end) + + it("returns false for non-input placeholders", function() + assert.is_false(input.has_inputs("{file} and {line}")) + end) + + it("returns true for bare input", function() + assert.is_true(input.has_inputs("echo {input}")) + end) + + it("returns true for input with prompt", function() + assert.is_true(input.has_inputs("{input:Name}")) + end) + end) + + describe("replace", function() + it("replaces bare input", function() + local result = input.replace("echo {input}", { input = "hello" }) + assert.are.equal("echo hello", result) + end) + + it("replaces input with prompt", function() + local result = input.replace("git checkout {input:Branch}", { ["input:Branch"] = "develop" }) + assert.are.equal("git checkout develop", result) + end) + + it("replaces duplicate placeholders", function() + local result = input.replace("{input} and {input}", { input = "val" }) + assert.are.equal("val and val", result) + end) + + it("replaces multiple distinct inputs", function() + local result = input.replace("mv {input:From} {input:To}", { + ["input:From"] = "a.txt", + ["input:To"] = "b.txt", + }) + assert.are.equal("mv a.txt b.txt", result) + end) + + it("leaves unresolved input placeholders intact", function() + local result = input.replace("{input:A} {input:B}", { ["input:A"] = "resolved" }) + assert.are.equal("resolved {input:B}", result) + end) + + it("does not touch non-input placeholders", function() + local result = input.replace("{file} {input}", { input = "val" }) + assert.are.equal("{file} val", result) + end) + end) + + describe("resolve", function() + it("collects values from vim.ui.input", function() + local input_calls = {} + vim.ui.input = function(opts, callback) + table.insert(input_calls, opts) + callback("user_value") + end + + local result + input.resolve({ "input" }, function(values) + result = values + end) + + assert.are.equal(1, #input_calls) + assert.are.equal("Input: ", input_calls[1].prompt) + assert.are.equal("", input_calls[1].default) + assert.is_not_nil(result) + assert.are.equal("user_value", result["input"]) + end) + + it("passes prompt and default to vim.ui.input", function() + local input_calls = {} + vim.ui.input = function(opts, callback) + table.insert(input_calls, opts) + callback("val") + end + + input.resolve({ "input:Branch:main" }, function() end) + + assert.are.equal("Branch: ", input_calls[1].prompt) + assert.are.equal("main", input_calls[1].default) + end) + + it("chains multiple inputs sequentially", function() + local call_count = 0 + vim.ui.input = function(opts, callback) + call_count = call_count + 1 + callback("val" .. call_count) + end + + local result + input.resolve({ "input:A", "input:B" }, function(values) + result = values + end) + + assert.are.equal(2, call_count) + assert.are.equal("val1", result["input:A"]) + assert.are.equal("val2", result["input:B"]) + end) + + it("calls on_done(nil) when user cancels", function() + vim.ui.input = function(opts, callback) + callback(nil) + end + + local result = "not_called" + input.resolve({ "input" }, function(values) + result = values + end) + + assert.is_nil(result) + end) + + it("aborts remaining inputs on cancel", function() + local call_count = 0 + vim.ui.input = function(opts, callback) + call_count = call_count + 1 + if call_count == 1 then + callback("first") + else + callback(nil) + end + end + + local result = "not_called" + input.resolve({ "input:A", "input:B", "input:C" }, function(values) + result = values + end) + + assert.are.equal(2, call_count) + assert.is_nil(result) + end) + + it("calls on_done immediately for empty keys", function() + local result + input.resolve({}, function(values) + result = values + end) + + assert.is_not_nil(result) + assert.are.equal(0, vim.tbl_count(result)) + end) + end) +end) diff --git a/tests/helpers_send.lua b/tests/helpers_send.lua index cdf175d..fea0038 100644 --- a/tests/helpers_send.lua +++ b/tests/helpers_send.lua @@ -11,6 +11,7 @@ local MODULES = { "wiremux.picker", "wiremux.utils.notify", "wiremux.context", + "wiremux.context.input", } function M.setup() @@ -39,6 +40,23 @@ function M.setup() return text end, }, + input = { + find = function() + return {} + end, + has_inputs = function() + return false + end, + replace = function(text) + return text + end, + resolve = function(keys, on_done) + on_done({}) + end, + parse = function(key) + return "Input", nil + end, + }, } helpers.register({ @@ -53,6 +71,7 @@ function M.setup() ["wiremux.picker"] = mocks.picker, ["wiremux.utils.notify"] = mocks.notify, ["wiremux.context"] = mocks.context, + ["wiremux.context.input"] = mocks.input, }) mocks.send = require("wiremux.action.send") diff --git a/tests/send_input_spec.lua b/tests/send_input_spec.lua new file mode 100644 index 0000000..e5ae11d --- /dev/null +++ b/tests/send_input_spec.lua @@ -0,0 +1,165 @@ +---@module 'luassert' + +local helpers = require("tests.helpers_send") + +describe("send with input placeholders", function() + local mocks + + before_each(function() + mocks = helpers.setup() + end) + + it("prompts user and sends expanded text", function() + local received_text + + mocks.input.find = function(text) + return { "input:Branch" } + end + mocks.input.resolve = function(keys, on_done) + on_done({ ["input:Branch"] = "develop" }) + end + mocks.input.replace = function(text, values) + return text:gsub("{input:Branch}", values["input:Branch"]) + end + + mocks.context.expand = function(text) + return text + end + + mocks.backend.send = function(text) + received_text = text + end + + mocks.action.run = function(opts, callbacks) + callbacks.on_targets({ + { id = "%1", kind = "pane", target = "test" }, + }, {}) + end + + mocks.send.send("git checkout {input:Branch}") + + assert.are.equal("git checkout develop", received_text) + end) + + it("aborts send when user cancels input", function() + local send_called = false + + mocks.input.find = function() + return { "input" } + end + mocks.input.resolve = function(keys, on_done) + on_done(nil) + end + + mocks.backend.send = function() + send_called = true + end + + mocks.action.run = function(opts, callbacks) + callbacks.on_targets({ + { id = "%1", kind = "pane", target = "test" }, + }, {}) + end + + mocks.send.send("echo {input}") + + assert.is_false(send_called) + end) + + it("expands sync placeholders before input resolution", function() + local received_text + local expand_order = {} + + mocks.context.expand = function(text) + table.insert(expand_order, "expand") + return text:gsub("{file}", "/path/to/file.lua") + end + + mocks.input.find = function(text) + table.insert(expand_order, "find") + return { "input:Name" } + end + mocks.input.resolve = function(keys, on_done) + table.insert(expand_order, "resolve") + on_done({ ["input:Name"] = "world" }) + end + mocks.input.replace = function(text, values) + return text:gsub("{input:Name}", values["input:Name"]) + end + + mocks.backend.send = function(text) + received_text = text + end + + mocks.action.run = function(opts, callbacks) + callbacks.on_targets({ + { id = "%1", kind = "pane", target = "test" }, + }, {}) + end + + mocks.send.send("echo {input:Name} in {file}") + + assert.are.equal("echo world in /path/to/file.lua", received_text) + -- Verify ordering: expand runs before input find/resolve + assert.are.same({ "expand", "find", "resolve" }, expand_order) + end) + + it("skips input resolution for text without input placeholders", function() + local resolve_called = false + + mocks.input.resolve = function() + resolve_called = true + end + + local received_text + + mocks.backend.send = function(text) + received_text = text + end + + mocks.action.run = function(opts, callbacks) + callbacks.on_targets({ + { id = "%1", kind = "pane", target = "test" }, + }, {}) + end + + mocks.send.send("plain text") + + assert.is_false(resolve_called) + assert.are.equal("plain text", received_text) + end) + + it("preserves {selection} when mixed with {input}", function() + local received_text + + -- Simulate: expand resolves {selection} but leaves {input:Name} untouched + -- Bare {input} is skipped explicitly in context.expand + mocks.context.expand = function(text) + return text:gsub("{selection}", "selected text") + end + + mocks.input.find = function(text) + return { "input:Name" } + end + mocks.input.resolve = function(keys, on_done) + on_done({ ["input:Name"] = "value" }) + end + mocks.input.replace = function(text, values) + return text:gsub("{input:Name}", values["input:Name"]) + end + + mocks.backend.send = function(text) + received_text = text + end + + mocks.action.run = function(opts, callbacks) + callbacks.on_targets({ + { id = "%1", kind = "pane", target = "test" }, + }, {}) + end + + mocks.send.send("echo {selection} {input:Name}") + + assert.are.equal("echo selected text value", received_text) + end) +end) From 43f10ced3b64d266e4e6fe426b498c5774d1641c Mon Sep 17 00:00:00 2001 From: jxyyz <132339113+jxyyz@users.noreply.github.com> Date: Tue, 24 Feb 2026 03:32:04 +0100 Subject: [PATCH 3/3] docs: add {input} placeholder documentation to README and help Document the {input} placeholder syntax in both README.md and doc/wiremux.txt with usage examples. - Add interactive input section to README with syntax table, code examples, and behavioral notes - Add matching section to vimdoc with {input} syntax list and examples --- README.md | 24 ++++++++++++++++++++++++ doc/wiremux.txt | 25 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/README.md b/README.md index c2815f3..ff79e63 100644 --- a/README.md +++ b/README.md @@ -287,6 +287,30 @@ require("wiremux").setup({ }) ``` +### Interactive input + +The `{input}` placeholder prompts the user via `vim.ui.input()` before sending. Use it when part of the text needs to come from the user at send time. + +| Syntax | Prompt label | Default value | +| ------------------------- | -------------- | ------------- | +| `{input}` | "Input" | — | +| `{input:Search query}` | "Search query" | — | +| `{input:Query:foo bar}` | "Query" | `foo bar` | +| `{input:URL:http://example.com}` | "URL" | `http://example.com` | + +```lua +-- Basic usage +require("wiremux").send("question:\n{input:Question}\n\ncontext:\n{this}") + +-- Single input with a default value +require("wiremux").send("grep -r '{input:Search query:TODO}' {file}") + +-- Multiple distinct inputs in one send +require("wiremux").send("git log --author='{input:Author}' --grep='{input:Grep pattern}'") +``` + +All sync placeholders (`{file}`, `{selection}`, etc.) resolve first, then each `{input}` prompts in order. Cancelling any prompt aborts the entire send. Text entered by the user is sent as-is — placeholders typed into the prompt are **not** expanded. + ## Target Resolution When you trigger an action (send, toggle, etc.), wiremux resolves which targets to use. Four options control this: **target**, **behavior**, **mode**, and **filters**. diff --git a/doc/wiremux.txt b/doc/wiremux.txt index 296808d..09afbc3 100644 --- a/doc/wiremux.txt +++ b/doc/wiremux.txt @@ -505,6 +505,31 @@ This is useful for conditional visibility in SendItem: } < +Interactive input ~ + *wiremux-input* +The `{input}` placeholder prompts the user via `vim.ui.input()` at send +time. Use it when part of the text needs to come from the user. + + {input} prompt with label "Input", no default + {input:Search query} prompt with label "Search query" + {input:Query:foo bar} prompt with label "Query", default "foo bar" + {input:URL:http://example.com} prompt "URL", default "http://example.com" + +>lua + -- Single input with a default value + require("wiremux").send("grep -r '{input:Search query:TODO}' {file}") + + -- Multiple distinct inputs in one send + require("wiremux").send( + "git log --author='{input:Author}' --grep='{input:Grep pattern}'" + ) +< + +All sync placeholders resolve first, then each `{input}` prompts in +order. Identical `{input}` expressions are deduplicated (prompted once, +reused everywhere). Cancelling any prompt aborts the entire send. +Placeholders typed into the prompt are sent as literal text (not expanded). + ============================================================================== 6. COMMANDS *wiremux-commands*