diff --git a/README.md b/README.md index e32889f..118b29d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # opencode-context.nvim -A Neovim plugin that enables seamless context sharing with running opencode sessions inside Tmux. Send your current buffer, all open buffers, visual selections, or diagnostics directly to opencode running in a Tmux pane for AI-assisted development. +A Neovim plugin that enables seamless context sharing with running opencode sessions inside tmux or Zellij. Send your current buffer, all open buffers, visual selections, or diagnostics directly to opencode running in a pane for AI-assisted development. ## Features @@ -11,14 +11,14 @@ A Neovim plugin that enables seamless context sharing with running opencode sess - 📍 `@cursor`, `@here` - Insert cursor position info - ✂️ `@selection`, `@range` - Insert visual selection - 🔍 `@diagnostics` - Insert LSP diagnostics -- 🖥️ **Tmux integration** - Send directly to opencode pane in current window +- 🖥️ **Tmux + Zellij integration** - Send directly to opencode pane in current window/tab - ⚡ LazyVim compatible with lazy loading ## Requirements - Neovim >= 0.8.0 -- `tmux` - Required for sending messages to running opencode sessions -- `opencode` running in a pane within the same tmux window as Neovim +- `tmux` or `zellij` - Required for sending messages to running opencode sessions +- `opencode` running in a pane within the same tmux window or zellij tab as Neovim ## Installation @@ -28,12 +28,22 @@ A Neovim plugin that enables seamless context sharing with running opencode sess { "cousine/opencode-context.nvim", opts = { + multiplexer = "auto", -- "auto", "tmux", or "zellij" tmux_target = nil, -- Manual override: "session:window.pane" + zellij_target = nil, -- Manual override: "terminal_3" auto_detect_pane = true, -- Auto-detect opencode pane in current window }, keys = { { "oc", "OpencodeSend", desc = "Send prompt to opencode" }, - { "oc", "OpencodeSend", mode = "v", desc = "Send prompt to opencode" }, + { + "oc", + function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "x", false) + require("opencode-context").send_prompt(true) + end, + mode = "v", + desc = "Send prompt to opencode", + }, { "ot", "OpencodeSwitchMode", desc = "Toggle opencode mode" }, { "op", "OpencodePrompt", desc = "Open opencode persistent prompt" }, }, @@ -48,7 +58,9 @@ use { 'cousine/opencode-context.nvim', config = function() require('opencode-context').setup({ + multiplexer = "auto", tmux_target = nil, + zellij_target = nil, auto_detect_pane = true, }) end @@ -63,13 +75,18 @@ Plug 'cousine/opencode-context.nvim' " Configuration in init.vim or init.lua lua << EOF require('opencode-context').setup({ + multiplexer = "auto", tmux_target = nil, + zellij_target = nil, auto_detect_pane = true, }) -- Keymaps vim.keymap.set("n", "oc", "OpencodeSend", { desc = "Send prompt to opencode" }) -vim.keymap.set("v", "oc", "OpencodeSend", { desc = "Send prompt to opencode" }) +vim.keymap.set("v", "oc", function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "x", false) + require("opencode-context").send_prompt(true) +end, { desc = "Send prompt to opencode" }) vim.keymap.set("n", "ot", "OpencodeSwitchMode", { desc = "Toggle opencode mode" }) vim.keymap.set("n", "op", "OpencodePrompt", { desc = "Open opencode persistent prompt" }) EOF @@ -83,7 +100,9 @@ call dein#add('cousine/opencode-context.nvim') " Configuration lua << EOF require('opencode-context').setup({ + multiplexer = "auto", tmux_target = nil, + zellij_target = nil, auto_detect_pane = true, }) EOF @@ -101,9 +120,11 @@ EOF ```lua require("opencode-context").setup({ - tmux_target = nil, - auto_detect_pane = true, - }) + multiplexer = "auto", + tmux_target = nil, + zellij_target = nil, + auto_detect_pane = true, + }) ``` ## Usage @@ -143,7 +164,7 @@ Use these placeholders in your prompts to include context: ### Workflow Example -1. In tmux, split your window: `Ctrl-b %` or `Ctrl-b "` +1. In your multiplexer, split your layout (`tmux`: `Ctrl-b %` / `Ctrl-b "`, `zellij`: `Alt n` by default) 2. Start opencode in one pane: `opencode` 3. Open Neovim in the other pane: `nvim` 4. From Neovim, press `oc` to open the prompt input @@ -154,40 +175,54 @@ Use these placeholders in your prompts to include context: ```lua require("opencode-context").setup({ + -- Multiplexer settings + multiplexer = "auto", -- "auto", "tmux", or "zellij" + -- Tmux settings tmux_target = nil, -- Manual override: "main:1.0" - auto_detect_pane = true, -- Auto-find opencode pane in current window (default: true) + auto_detect_pane = true, -- Auto-find opencode pane in current tmux window/zellij tab (default: true) + + -- Zellij settings + zellij_target = nil, -- Manual override: "terminal_3" }) ``` ## How It Works -The plugin integrates directly with tmux to send messages to your running opencode session: +The plugin integrates directly with tmux or zellij to send messages to your running opencode session: -1. **Auto-detects** running opencode pane in the current tmux window -2. **Sends keystrokes directly** to the opencode pane using `tmux send-keys` +1. **Auto-detects** running opencode pane in the current tmux window or zellij tab +2. **Sends keystrokes directly** to the opencode pane using `tmux send-keys` or `zellij action` 3. **You see the conversation** in real-time in your opencode interface 4. **No new processes** - uses your existing opencode session ### Detection Strategy -The plugin searches for opencode panes **only in the current tmux window** using: +For tmux, the plugin searches for opencode panes **only in the current tmux window** using: - Current command is `opencode` - Pane title contains "opencode" - Recent command history contains opencode +For zellij, the plugin searches terminal panes in the **current active tab** and matches panes by command/title containing `opencode`. + This ensures it finds the opencode instance you're actively working with, not some other session. ## Troubleshooting -### "No opencode pane found in current window" +### "No opencode pane found in current tmux window" - **Same window**: Ensure opencode is running in a pane in the **same tmux window** as Neovim - **Split window**: Use `Ctrl-b %` or `Ctrl-b "` to split and run opencode in one pane - **Manual target**: Set `tmux_target = "session:window.pane"` in config to override detection - **Verify opencode is running**: Check that opencode is actually running in the current window +### "No opencode pane found in current zellij tab" + +- **Same tab**: Ensure opencode is running in a pane in the **same zellij tab** as Neovim +- **Manual target**: Set `zellij_target = "terminal_3"` in config to override detection +- **Verify opencode is running**: Check pane command/title includes opencode + ### "Failed to send to opencode pane" - **Permissions**: Check tmux pane permissions @@ -205,6 +240,16 @@ tmux send-keys -t session:window.pane "test message" Enter # List all panes in current window tmux list-panes -F '#{session_name}:#{window_index}.#{pane_index} #{pane_current_command}' + +# Check zellij panes in current session +zellij action list-panes --json + +# Check current zellij tab info +zellij action current-tab-info --json + +# Test manual zellij send +zellij action write-chars --pane-id terminal_3 "test message" +zellij action send-keys --pane-id terminal_3 Enter ``` ## Contributing diff --git a/lazy.lua b/lazy.lua index 9b012b7..7ca13bd 100644 --- a/lazy.lua +++ b/lazy.lua @@ -2,13 +2,27 @@ return { "opencode-context.nvim", dev = true, opts = { + -- Multiplexer settings + multiplexer = "auto", -- "auto", "tmux", or "zellij" + -- Tmux settings tmux_target = nil, -- Manual override: "session:window.pane" auto_detect_pane = true, -- Auto-detect opencode pane in current window + + -- Zellij settings + zellij_target = nil, -- Manual override: "terminal_3" }, keys = { { "oc", "OpencodeSend", desc = "Send prompt to opencode" }, - { "oc", "OpencodeSend", mode = "v", desc = "Send prompt to opencode" }, + { + "oc", + function() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "x", false) + require("opencode-context").send_prompt(true) + end, + mode = "v", + desc = "Send prompt to opencode", + }, { "om", "OpencodeSwitchMode", desc = "Toggle opencode mode" }, }, cmd = { @@ -18,4 +32,4 @@ return { config = function(_, opts) require("opencode-context").setup(opts) end, -} \ No newline at end of file +} diff --git a/lua/opencode-context/init.lua b/lua/opencode-context/init.lua index 6cd78f0..7058668 100644 --- a/lua/opencode-context/init.lua +++ b/lua/opencode-context/init.lua @@ -2,11 +2,61 @@ local M = {} local ui = require("opencode-context.ui") M.config = { + -- Multiplexer settings + multiplexer = "auto", -- "auto", "tmux", or "zellij" + -- Tmux settings tmux_target = nil, -- Manual override: "session:window.pane" auto_detect_pane = true, -- Auto-detect opencode pane in current window + + -- Zellij settings + zellij_target = nil, -- Manual override: "terminal_3" } +local function trim(value) + return (value or ""):gsub("^%s+", ""):gsub("%s+$", "") +end + +local function decode_json(value) + if not value or value == "" then + return nil + end + + local ok, decoded = pcall(vim.fn.json_decode, value) + if not ok then + return nil + end + + return decoded +end + +local function run_system_command(cmd) + local output = vim.fn.system(cmd) + if vim.v.shell_error ~= 0 then + return nil + end + + return output +end + +local function zellij_supports_send_keys() + if M._zellij_supports_send_keys ~= nil then + return M._zellij_supports_send_keys + end + + M._zellij_supports_send_keys = run_system_command("zellij action send-keys --help 2>/dev/null") ~= nil + return M._zellij_supports_send_keys +end + +local function zellij_supports_tab_info() + if M._zellij_supports_tab_info ~= nil then + return M._zellij_supports_tab_info + end + + M._zellij_supports_tab_info = run_system_command("zellij action current-tab-info --help 2>/dev/null") ~= nil + return M._zellij_supports_tab_info +end + local function get_current_file_path() local bufnr = vim.api.nvim_get_current_buf() local filename = vim.api.nvim_buf_get_name(bufnr) @@ -184,8 +234,31 @@ local function replace_placeholders(prompt) return prompt end -local function find_opencode_pane() - -- If manual target is set, use it +local function get_active_multiplexer() + if M.config.multiplexer == "tmux" or M.config.multiplexer == "zellij" then + return M.config.multiplexer + end + + if M.config.tmux_target then + return "tmux" + end + + if M.config.zellij_target then + return "zellij" + end + + if vim.env.TMUX and vim.env.TMUX ~= "" then + return "tmux" + end + + if vim.env.ZELLIJ and vim.env.ZELLIJ ~= "" then + return "zellij" + end + + return nil +end + +local function find_opencode_tmux_pane() if M.config.tmux_target then return M.config.tmux_target end @@ -194,7 +267,6 @@ local function find_opencode_pane() return nil end - -- Get current session and window local current_session_cmd = "tmux display-message -p '#{session_name}'" local current_window_cmd = "tmux display-message -p '#{window_index}'" @@ -205,32 +277,26 @@ local function find_opencode_pane() return nil end - local current_session = session_handle:read("*a"):gsub("\n", "") - local current_window = window_handle:read("*a"):gsub("\n", "") + local current_session = trim(session_handle:read("*a")) + local current_window = trim(window_handle:read("*a")) session_handle:close() window_handle:close() - if not current_session or current_session == "" or not current_window or current_window == "" then + if current_session == "" or current_window == "" then return nil end - -- Search for opencode pane in current window only local strategies = { - -- Current command is opencode in current window string.format( "tmux list-panes -t %s:%s -F '#{session_name}:#{window_index}.#{pane_index}' -f '#{==:#{pane_current_command},opencode}'", current_session, current_window ), - - -- Pane title contains opencode in current window string.format( "tmux list-panes -t %s:%s -F '#{session_name}:#{window_index}.#{pane_index}' -f '#{m:*opencode*,#{pane_title}}'", current_session, current_window ), - - -- Recent command history contains opencode in current window string.format( "tmux list-panes -t %s:%s -F '#{session_name}:#{window_index}.#{pane_index} #{pane_start_command}' | grep opencode | head -1 | cut -d' ' -f1", current_session, @@ -241,9 +307,9 @@ local function find_opencode_pane() for _, cmd in ipairs(strategies) do local handle = io.popen(cmd .. " 2>/dev/null") if handle then - local result = handle:read("*a"):gsub("\n", "") + local result = trim(handle:read("*a")) handle:close() - if result and result ~= "" then + if result ~= "" then return result end end @@ -252,35 +318,172 @@ local function find_opencode_pane() return nil end -local function send_to_opencode(message) - local pane = find_opencode_pane() - if not pane then +local function find_opencode_zellij_pane() + if M.config.zellij_target then + return M.config.zellij_target + end + + if not M.config.auto_detect_pane then + return nil + end + + if not zellij_supports_tab_info() then + return vim.env.ZELLIJ_PANE_ID + end + + local current_tab_info = run_system_command("zellij action current-tab-info --json 2>/dev/null") + if not current_tab_info then + return nil + end + + local current_tab = decode_json(current_tab_info) + if not current_tab or current_tab.tab_id == nil then + return nil + end + + local panes_info = run_system_command("zellij action list-panes --json 2>/dev/null") + if not panes_info then + return nil + end + + local panes = decode_json(panes_info) + if type(panes) ~= "table" then + return nil + end + + -- First look for an exact pane titled opencode + for _, pane in ipairs(panes) do + if pane.is_plugin == false and pane.tab_id == current_tab.tab_id then + local title = (pane.title or ""):lower() + local command = (pane.pane_command or pane["pane-command"] or ""):lower() + if title == "opencode" or command == "opencode" then + return string.format("terminal_%d", pane.id) + end + end + end + + -- then do a substring match + for _, pane in ipairs(panes) do + if pane.is_plugin == false and pane.tab_id == current_tab.tab_id then + local title = (pane.title or ""):lower() + local command = (pane.pane_command or pane["pane-command"] or ""):lower() + if string.find(command, "opencode") ~= nil or string.find(title, "opencode") ~= nil then + return string.format("terminal_%d", pane.id) + end + end + end + + return nil +end + +local function find_opencode_target(multiplexer) + if multiplexer == "tmux" then + return find_opencode_tmux_pane() + end + + if multiplexer == "zellij" then + return find_opencode_zellij_pane() + end + + return nil +end + +local function notify_missing_multiplexer() + vim.notify( + "No supported terminal multiplexer detected. Start Neovim in tmux or zellij, or configure multiplexer/target explicitly.", + vim.log.levels.ERROR + ) +end + +local function notify_missing_target(multiplexer) + if multiplexer == "tmux" then vim.notify( - "No opencode pane found in current window. Make sure opencode is running in a pane in this tmux window.", + "No opencode pane found in current tmux window. Make sure opencode is running in this tmux window.", vim.log.levels.ERROR ) + return + end + + vim.notify( + "No opencode pane found in current zellij tab. Make sure opencode is running in this zellij tab.", + vim.log.levels.ERROR + ) +end + +local function resolve_opencode_target() + local multiplexer = get_active_multiplexer() + if not multiplexer then + notify_missing_multiplexer() + return nil, nil + end + + local target = find_opencode_target(multiplexer) + if not target then + notify_missing_target(multiplexer) + return multiplexer, nil + end + + return multiplexer, target +end + +local function send_to_opencode(message) + local multiplexer, target = resolve_opencode_target() + if not multiplexer or not target then return false end - -- Send message directly to the pane - local cmd = string.format("tmux send-keys -t %s %s", pane, vim.fn.shellescape(message)) - vim.fn.system(cmd) - vim.fn.system(string.format("tmux send-keys -t %s C-m", pane)) + local success = false - if vim.v.shell_error == 0 then - vim.notify(string.format("Sent prompt to opencode pane (%s)", pane), vim.log.levels.INFO) - return true + if multiplexer == "tmux" then + local write_cmd = string.format( + "tmux send-keys -t %s %s", + vim.fn.shellescape(target), + vim.fn.shellescape(message) + ) + local enter_cmd = string.format("tmux send-keys -t %s C-m", vim.fn.shellescape(target)) + + if run_system_command(write_cmd) and run_system_command(enter_cmd) then + success = true + end else - vim.notify("Failed to send to opencode pane", vim.log.levels.ERROR) - return false + local write_cmd + local enter_cmd + + if zellij_supports_send_keys() then + write_cmd = string.format( + "zellij action write-chars --pane-id %s %s", + vim.fn.shellescape(target), + vim.fn.shellescape(message) + ) + enter_cmd = string.format( + "zellij action send-keys --pane-id %s Enter", + vim.fn.shellescape(target) + ) + else + write_cmd = string.format("zellij action write-chars %s", vim.fn.shellescape(message)) + enter_cmd = "zellij action write 13" + end + + if run_system_command(write_cmd) and run_system_command(enter_cmd) then + success = true + end end + + if success then + vim.notify(string.format("Sent prompt to opencode pane (%s via %s)", target, multiplexer), vim.log.levels.INFO) + return true + end + + vim.notify(string.format("Failed to send prompt via %s", multiplexer), vim.log.levels.ERROR) + return false end -function M.send_prompt() - -- Check if we're in visual mode and pre-populate with @selection +function M.send_prompt(use_selection_default) + -- Visual mappings can exit to Normal mode before opening the prompt, + -- so allow callers to explicitly preserve the @selection default. local mode = vim.fn.mode() local default_text = "" - if mode == "v" or mode == "V" or mode == "\22" then -- \22 is visual block mode + if use_selection_default or mode == "v" or mode == "V" or mode == "\22" then -- \22 is visual block mode default_text = "@selection " end @@ -298,26 +501,29 @@ function M.send_prompt() end function M.toggle_mode() - local pane = find_opencode_pane() - if not pane then - vim.notify( - "No opencode pane found in current window. Make sure opencode is running in a pane in this tmux window.", - vim.log.levels.ERROR - ) + local multiplexer, target = resolve_opencode_target() + if not multiplexer or not target then return false end - -- Send tab key to toggle between planning/build mode - local cmd = string.format("tmux send-keys -t %s Tab", pane) - vim.fn.system(cmd) + local cmd + if multiplexer == "tmux" then + cmd = string.format("tmux send-keys -t %s Tab", vim.fn.shellescape(target)) + else + if zellij_supports_send_keys() then + cmd = string.format("zellij action send-keys --pane-id %s Tab", vim.fn.shellescape(target)) + else + cmd = "zellij action write 9" + end + end - if vim.v.shell_error == 0 then - vim.notify(string.format("Toggled opencode mode (%s)", pane), vim.log.levels.INFO) + if run_system_command(cmd) then + vim.notify(string.format("Toggled opencode mode (%s via %s)", target, multiplexer), vim.log.levels.INFO) return true - else - vim.notify("Failed to toggle opencode mode", vim.log.levels.ERROR) - return false end + + vim.notify(string.format("Failed to toggle opencode mode via %s", multiplexer), vim.log.levels.ERROR) + return false end -- Create a callback that processes placeholders and sends to opencode @@ -342,6 +548,8 @@ end function M.setup(opts) M.config = vim.tbl_deep_extend("force", M.config, opts or {}) + M._zellij_supports_send_keys = nil + M._zellij_supports_tab_info = nil end return M diff --git a/lua/opencode.lua b/lua/opencode.lua new file mode 100644 index 0000000..08cdf77 --- /dev/null +++ b/lua/opencode.lua @@ -0,0 +1,16 @@ +local context = require("opencode-context") + +local M = setmetatable({}, { + __index = context, +}) + +-- Backward-compatible aliases +M.toggle = function() + return context.toggle_mode() +end + +M.send = function() + return context.send_prompt() +end + +return M diff --git a/plugin/opencode.lua b/plugin/opencode.lua index 263ff8b..7838a49 100644 --- a/plugin/opencode.lua +++ b/plugin/opencode.lua @@ -5,6 +5,11 @@ vim.g.loaded_opencode = 1 local opencode = require("opencode-context") +local function send_visual_prompt() + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("", true, false, true), "x", false) + opencode.send_prompt(true) +end + vim.api.nvim_create_user_command("OpencodeSend", function() opencode.send_prompt() end, { @@ -25,7 +30,7 @@ end, { local function create_keymaps() vim.keymap.set("n", "oc", opencode.send_prompt, { desc = "Send prompt to opencode" }) - vim.keymap.set("v", "oc", opencode.send_prompt, { desc = "Send prompt to opencode" }) + vim.keymap.set("v", "oc", send_visual_prompt, { desc = "Send prompt to opencode" }) vim.keymap.set("n", "ot", opencode.toggle_mode, { desc = "Toggle opencode mode" }) vim.keymap.set("n", "op", opencode.toggle_persistent_prompt, { desc = "Toggle persistent opencode prompt" }) end @@ -34,4 +39,3 @@ vim.api.nvim_create_autocmd("VimEnter", { callback = create_keymaps, once = true, }) - diff --git a/tests/zellij_integration.lua b/tests/zellij_integration.lua new file mode 100644 index 0000000..4657301 --- /dev/null +++ b/tests/zellij_integration.lua @@ -0,0 +1,96 @@ +local source = debug.getinfo(1, "S").source:sub(2) +local root = vim.fn.fnamemodify(source, ":h:h") + +vim.opt.runtimepath:prepend(root) + +local function has_command(commands, needle) + for _, command in ipairs(commands) do + if command:find(needle, 1, true) then + return true + end + end + return false +end + +local original_system = vim.fn.system +local original_input = vim.ui.input +local original_notify = vim.notify +local original_zellij = vim.env.ZELLIJ +local original_tmux = vim.env.TMUX + +local observed_commands = {} +local notifications = {} + +local function teardown() + vim.fn.system = original_system + vim.ui.input = original_input + vim.notify = original_notify + vim.env.ZELLIJ = original_zellij + vim.env.TMUX = original_tmux +end + +local ok, err = pcall(function() + original_system("true") + + vim.env.ZELLIJ = "0" + vim.env.TMUX = "" + + vim.notify = function(message, level) + table.insert(notifications, { message = message, level = level }) + end + + vim.fn.system = function(command) + table.insert(observed_commands, command) + + if command:find("zellij action current-tab-info --json", 1, true) then + return '{"tab_id":1}' + end + + if command:find("zellij action list-panes --json", 1, true) then + return '[{"id":3,"is_plugin":false,"tab_id":1,"pane_command":"opencode","title":"opencode"}]' + end + + return "" + end + + vim.ui.input = function(_, on_confirm) + on_confirm("hello from zellij test") + end + + package.loaded["opencode-context"] = nil + local opencode_context = require("opencode-context") + opencode_context.setup({ + multiplexer = "auto", + auto_detect_pane = true, + }) + + local toggle_ok = opencode_context.toggle_mode() + assert(toggle_ok == true, "expected toggle_mode() to succeed with zellij") + + opencode_context.send_prompt() + + assert(has_command(observed_commands, "zellij action current-tab-info --json"), "missing current-tab-info call") + assert(has_command(observed_commands, "zellij action list-panes --json"), "missing list-panes call") + assert(has_command(observed_commands, "zellij action send-keys --pane-id 'terminal_3' Tab"), "missing zellij Tab send") + assert(has_command(observed_commands, "zellij action write-chars --pane-id 'terminal_3'"), "missing zellij write-chars") + assert(has_command(observed_commands, "hello from zellij test"), "missing prompt payload in write-chars") + assert(has_command(observed_commands, "zellij action send-keys --pane-id 'terminal_3' Enter"), "missing zellij Enter send") + + local saw_success_notification = false + for _, item in ipairs(notifications) do + if item.message:find("via zellij", 1, true) then + saw_success_notification = true + break + end + end + + assert(saw_success_notification, "expected success notification mentioning zellij") +end) + +teardown() + +if not ok then + error(err) +end + +print("zellij integration test passed") diff --git a/tests/zellij_nomock.lua b/tests/zellij_nomock.lua new file mode 100644 index 0000000..4d92c15 --- /dev/null +++ b/tests/zellij_nomock.lua @@ -0,0 +1,134 @@ +local source = debug.getinfo(1, "S").source:sub(2) +local root = vim.fn.fnamemodify(source, ":h:h") + +vim.opt.runtimepath:prepend(root) + +local function trim(value) + return (value or ""):gsub("^%s+", ""):gsub("%s+$", "") +end + +local function run(cmd) + local output = vim.fn.system(cmd) + if vim.v.shell_error ~= 0 then + error(string.format("Command failed: %s\n%s", cmd, output)) + end + return output +end + +local function command_help(cmd) + return vim.fn.system(cmd .. " --help 2>/dev/null") or "" +end + +local function supports_dump_screen_pane_id() + return command_help("zellij action dump-screen"):find("--pane%-id") ~= nil +end + +local function supports_close_pane_pane_id() + return command_help("zellij action close-pane"):find("--pane%-id") ~= nil +end + +local function wait_for_file(path, timeout_ms) + local deadline = vim.loop.hrtime() + (timeout_ms * 1000000) + while vim.loop.hrtime() < deadline do + if vim.fn.filereadable(path) == 1 then + return true + end + vim.wait(50) + end + return false +end + +local function read_first_line(path) + local lines = vim.fn.readfile(path) + if #lines == 0 then + return nil + end + return trim(lines[1]) +end + +if not vim.env.ZELLIJ or vim.env.ZELLIJ == "" then + error("zellij_nomock.lua must be executed inside a zellij session") +end + +local capture_pane_id = nil +local capture_id_file = string.format("/tmp/opencode-context-zellij-pane-id-%d.txt", vim.fn.getpid()) +local dump_file = string.format("/tmp/opencode-context-zellij-dump-%d.txt", vim.fn.getpid()) +local ok, err = pcall(function() + pcall(vim.fn.delete, capture_id_file) + pcall(vim.fn.delete, dump_file) + + local pane_name = string.format("opencode-context-test-%d", vim.loop.hrtime()) + local inner_cmd = string.format("printf '%%s\n' \"$ZELLIJ_PANE_ID\" > %s; cat", vim.fn.shellescape(capture_id_file)) + local create_cmd = string.format( + "zellij action new-pane --direction right --name %s -- sh -lc %s", + vim.fn.shellescape(pane_name), + vim.fn.shellescape(inner_cmd) + ) + run(create_cmd) + + -- Older zellij versions do not support --pane-id targeting for write/send-keys. + -- In that case, input goes to the focused pane, so move focus to the capture pane. + pcall(run, "zellij action move-focus right") + + assert(wait_for_file(capture_id_file, 3000), "failed to create capture pane") + capture_pane_id = read_first_line(capture_id_file) + assert(capture_pane_id and capture_pane_id ~= "", "failed to read capture pane id") + + package.loaded["opencode-context"] = nil + local opencode_context = require("opencode-context") + + opencode_context.setup({ + multiplexer = "zellij", + zellij_target = capture_pane_id, + auto_detect_pane = false, + }) + + local prompt_payload = "zellij-real-test-payload" + local original_input = vim.ui.input + + vim.ui.input = function(_, on_confirm) + on_confirm(prompt_payload) + end + + local send_ok, send_err = pcall(function() + opencode_context.send_prompt() + end) + + vim.ui.input = original_input + + assert(send_ok, send_err) + assert(opencode_context.toggle_mode() == true, "expected toggle_mode to succeed") + + vim.wait(300) + + pcall(run, "zellij action move-focus right") + + if supports_dump_screen_pane_id() then + run(string.format("zellij action dump-screen --pane-id %s --full %s", vim.fn.shellescape(capture_pane_id), vim.fn.shellescape(dump_file))) + else + run(string.format("zellij action dump-screen --full %s", vim.fn.shellescape(dump_file))) + end + + local lines = vim.fn.readfile(dump_file) + local screen_dump = table.concat(lines, "\n") + + assert(screen_dump:find(prompt_payload, 1, true), "sent prompt not found in capture pane dump") +end) + +if capture_pane_id then + if supports_close_pane_pane_id() then + vim.fn.system(string.format("zellij action close-pane --pane-id %s", vim.fn.shellescape(capture_pane_id))) + else + pcall(run, "zellij action move-focus right") + vim.fn.system("zellij action close-pane") + end +end + +pcall(vim.fn.delete, capture_id_file) +pcall(vim.fn.delete, dump_file) + +if not ok then + error(err) +end + +print("zellij no-mock integration test passed")