From 610a264896ff6bf707a9b5d313bdd4bc681bf24d Mon Sep 17 00:00:00 2001 From: Michael Del Rosario <51274850+raydelrosario@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:24:52 -0500 Subject: [PATCH] feat: support ad-hoc channels --- README.md | 44 ++++++++++++++ lua/tv/channels.lua | 135 +++++++++++++++++++++++++++++++++++++++++++ lua/tv/init.lua | 2 + tests/tv/tv_spec.lua | 89 ++++++++++++++++++++++++++++ 4 files changed, 270 insertions(+) diff --git a/README.md b/README.md index 166ef46..a9ca159 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,17 @@ TV comes with 30+ built-in channels. Use `:Tv` to see all available channels, or Tab completion works: `:Tv ` +### Ad-hoc Channels + +`pick(entries, opts)` lets Neovim integrations supply entries to a Television +ad-hoc channel. + +- `entries` is the list of strings Television searches. +- `opts.args` contains optional Television CLI arguments; `pick` manages + `--source-command` and `--expect`. +- `opts.handlers` is a required map from keys such as `` to `tv.Handler` + functions. Each handler receives the selected entries and current `tv.Config`. + ## Configuration By default, tv.nvim passes no extra command-line arguments to `tv`: your television configuration (status bar, preview @@ -265,6 +276,39 @@ Here's a more comprehensive configuration example demonstrating the plugin's cap auto_open = true, -- automatically open quickfix window after populating }, }) + + -- ad-hoc channel: browse listed Neovim buffers + vim.keymap.set("n", "fb", function() + local buffers = {} + for _, buffer in ipairs(vim.fn.getbufinfo({ buflisted = 1 })) do + if buffer.name ~= "" and vim.fn.filereadable(buffer.name) == 1 then + -- encode the buffer number, display name, and file path as tab-separated fields + table.insert(buffers, ("%d\t%s\t%s"):format( + buffer.bufnr, + vim.fn.fnamemodify(buffer.name, ":~:."), + vim.fn.shellescape(buffer.name) + )) + end + end + + require("tv").pick(buffers, { + args = { + "--input-header", "Buffers", -- label the Television input + "--source-display", "{split:\\t:1}", -- show the buffer name + "--source-output", "{split:\\t:0}", -- return the buffer number + "--preview-command", "bat -n --color=always {split:\\t:2}", -- preview the file with bat + }, + handlers = { + [""] = function(entries) + local bufnr = tonumber(entries[1]) + if bufnr then + -- switch to the buffer number returned by Television + vim.api.nvim_set_current_buf(bufnr) + end + end, + }, + }) + end, { desc = "Find buffers" }) -- describe the Neovim mapping end, }, ``` diff --git a/lua/tv/channels.lua b/lua/tv/channels.lua index ef85e9b..3752087 100644 --- a/lua/tv/channels.lua +++ b/lua/tv/channels.lua @@ -109,6 +109,141 @@ function M.launch(channel_name, prompt_input) launch_channel(channel_name, channel_config.handlers, prompt_input) end +---@class tv.PickOptions +---@field args? string[] +---@field handlers table + +local function selects_automatically(args, entry_count) + for _, arg in ipairs(args) do + if arg == "--take-1" or arg == "--take-1-fast" or (arg == "--select-1" and entry_count == 1) then + return true + end + end + return false +end + +local function launch_picker(entries, opts) + local source_path = vim.fn.tempname() + if vim.fn.writefile(entries, source_path) ~= 0 then + vim.fn.delete(source_path) + vim.notify("Failed to write ad-hoc channel entries", vim.log.levels.ERROR, { title = "tv.nvim" }) + return + end + + local args = opts.args or {} + local cmd = { config.current.tv_binary } + vim.list_extend(cmd, args) + + local read_command = vim.fn.has("win32") == 1 and "type" or "cat" + vim.list_extend(cmd, { "--source-command", read_command .. " " .. vim.fn.shellescape(source_path) }) + + local handlers_by_key = {} + local expect_keys = {} + for nvim_key, handler in pairs(opts.handlers) do + local tv_key = utils.convert_keybinding_to_tv_format(nvim_key) + if tv_key then + handlers_by_key[tv_key] = handler + table.insert(expect_keys, tv_key) + end + end + + table.sort(expect_keys) + local has_expect = not selects_automatically(args, #entries) and #expect_keys > 0 + if has_expect then + vim.list_extend(cmd, { "--expect", table.concat(expect_keys, ";") }) + end + + local tv_window = window.create() + local tv_buffer = vim.api.nvim_win_get_buf(tv_window) + local error_output = {} + + local function finish() + vim.fn.delete(source_path) + pcall(vim.api.nvim_win_close, tv_window, true) + end + + local job = vim.fn.jobstart(cmd, { + on_stderr = function(_, data) + if data then + for _, line in ipairs(data) do + if line ~= "" then + table.insert(error_output, line) + end + end + end + end, + on_exit = function(_, exit_code) + local output = vim.api.nvim_buf_is_valid(tv_buffer) and vim.api.nvim_buf_get_lines(tv_buffer, 0, -1, false) or {} + finish() + + if exit_code ~= 0 then + local error_msg = "TV exited with code " .. exit_code + if #error_output > 0 then + error_msg = error_msg .. ":\n" .. table.concat(error_output, "\n") + end + vim.notify(error_msg, vim.log.levels.ERROR, { title = "tv.nvim" }) + return + end + + if #error_output > 0 then + vim.notify(table.concat(error_output, "\n"), vim.log.levels.WARN, { title = "tv.nvim" }) + end + + local handler + local start_idx = 1 + if has_expect then + handler = handlers_by_key[vim.trim(output[1] or "")] + start_idx = 2 + else + handler = handlers_by_key.enter + end + + local selected = {} + for i = start_idx, #output do + if output[i] ~= "" then + table.insert(selected, output[i]) + end + end + + if handler and #selected > 0 then + handler(selected, config.current) + end + end, + term = true, + }) + + if job <= 0 then + finish() + vim.notify("Failed to start TV", vim.log.levels.ERROR, { title = "tv.nvim" }) + return + end + + vim.cmd("startinsert") +end + +---Open a Television ad-hoc channel for the given entries +---@param entries string[] +---@param opts tv.PickOptions +function M.pick(entries, opts) + vim.validate({ + entries = { entries, "table" }, + opts = { opts, "table" }, + }) + vim.validate({ + args = { opts.args, "table", true }, + handlers = { opts.handlers, "table" }, + }) + + if #entries == 0 then + return + end + if vim.tbl_isempty(opts.handlers) then + error("opts.handlers must not be empty") + end + + launch_picker(entries, opts) +end + function M.select() local handle = io.popen(config.current.tv_binary .. " list-channels 2>/dev/null") if not handle then diff --git a/lua/tv/init.lua b/lua/tv/init.lua index e641f7b..f067acd 100644 --- a/lua/tv/init.lua +++ b/lua/tv/init.lua @@ -8,6 +8,7 @@ local M = {} ---@class tv.Module ---@field tv_channel fun(channel: string, prompt_input?: string): nil ---@field tv_channels fun(): nil +---@field pick fun(entries: string[], opts: tv.PickOptions): nil ---@field tv_files fun(prompt_input?: string): nil ---@field tv_text fun(prompt_input?: string): nil ---@field setup fun(opts?: tv.Config): nil @@ -28,6 +29,7 @@ local M = {} M.tv_channel = channels.launch M.tv_channels = channels.select +M.pick = channels.pick M.handlers = handlers M.config = config.current diff --git a/tests/tv/tv_spec.lua b/tests/tv/tv_spec.lua index 93204ab..6ba7d85 100644 --- a/tests/tv/tv_spec.lua +++ b/tests/tv/tv_spec.lua @@ -131,6 +131,95 @@ describe("tv.nvim", function() end) end) + describe("ad-hoc channels", function() + local original = {} + local state + + before_each(function() + state = {} + original.jobstart = vim.fn.jobstart + original.tempname = vim.fn.tempname + + vim.fn.tempname = function() + state.source_path = original.tempname() + return state.source_path + end + vim.fn.jobstart = function(command, options) + state.job = { command = vim.deepcopy(command), options = options } + state.window = vim.api.nvim_get_current_win() + state.buffer = vim.api.nvim_get_current_buf() + return 1 + end + end) + + after_each(function() + vim.fn.jobstart = original.jobstart + vim.fn.tempname = original.tempname + + if state.window and vim.api.nvim_win_is_valid(state.window) then + vim.api.nvim_win_close(state.window, true) + end + if state.buffer and vim.api.nvim_buf_is_valid(state.buffer) then + vim.api.nvim_buf_delete(state.buffer, { force = true }) + end + if state.source_path then + vim.fn.delete(state.source_path) + end + end) + + it("passes arguments and routes exact selections", function() + local received + tv.pick({ "1\tFirst", "2\tSecond" }, { + args = { "--source-display", "{split:\\t:1}" }, + handlers = { + [""] = function() end, + [""] = function(entries) + received = entries + end, + }, + }) + + local read_command = vim.fn.has("win32") == 1 and "type" or "cat" + assert.are.same({ + "tv", + "--source-display", + "{split:\\t:1}", + "--source-command", + read_command .. " " .. vim.fn.shellescape(state.source_path), + "--expect", + "ctrl-s;enter", + }, state.job.command) + vim.api.nvim_buf_set_lines(state.buffer, 0, -1, false, { "ctrl-s", " first ", "second" }) + state.job.options.on_exit(1, 0) + + assert.are.same({ " first ", "second" }, received) + assert.are.equal(0, vim.fn.filereadable(state.source_path)) + end) + + it("does not mistake an automatic selection for a confirmation key", function() + local received + local ctrl_s_called = false + tv.pick({ "ctrl-s" }, { + args = { "--take-1" }, + handlers = { + [""] = function(entries) + received = entries + end, + [""] = function() + ctrl_s_called = true + end, + }, + }) + + assert.is_false(vim.tbl_contains(state.job.command, "--expect")) + vim.api.nvim_buf_set_lines(state.buffer, 0, -1, false, { "ctrl-s" }) + state.job.options.on_exit(1, 0) + + assert.are.same({ "ctrl-s" }, received) + assert.is_false(ctrl_s_called) + end) + end) + describe("_convert_keybinding_to_tv_format", function() local convert = tv._convert_keybinding_to_tv_format