From 202ac26ccf2ff4845e4778283feb134f45b7e5b4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:29:16 +0000 Subject: [PATCH 1/9] Initial plan From cd9b898e4617ad08118027424dba3f523e2b3526 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:36:35 +0000 Subject: [PATCH 2/9] Add custom chat frontend with floating window UI Co-authored-by: verlihirsh <6280012+verlihirsh@users.noreply.github.com> --- README.md | 31 +++ ftplugin/opencode_chat.lua | 31 +++ lua/opencode.lua | 2 + lua/opencode/ui/chat.lua | 355 ++++++++++++++++++++++++++++++++ lua/opencode/ui/chat_events.lua | 109 ++++++++++ lua/opencode/ui/chat_init.lua | 44 ++++ 6 files changed, 572 insertions(+) create mode 100644 ftplugin/opencode_chat.lua create mode 100644 lua/opencode/ui/chat.lua create mode 100644 lua/opencode/ui/chat_events.lua create mode 100644 lua/opencode/ui/chat_init.lua diff --git a/README.md b/README.md index 9abcf9b7..c13ce9bb 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,37 @@ Please submit PRs adding new providers! 🙂 ## 🚀 Usage +### 💬 Chat — `require("opencode").chat()` + +Open a custom Neovim frontend for `opencode` with a floating window chat interface. + +- **Pure Neovim UI** — no terminal needed +- **Real-time streaming** — see AI responses as they're generated +- **Vim keybindings** — navigate with hjkl, yank messages, and more +- **Session management** — create new sessions, interrupt responses +- **Markdown rendering** — syntax highlighting for code blocks + +#### Keybindings + +| Key | Action | +| --- | ------ | +| `i` or `a` | Send a message | +| `n` | Start a new session | +| `q` or `` | Close chat window | +| `yy` | Yank current message to clipboard | +| `` | Interrupt current response | +| `j`/`k` | Navigate up/down | +| `gg`/`G` | Jump to top/bottom | + +#### Example Setup + +```lua +-- Add to your keymaps +vim.keymap.set('n', 'oc', function() + require('opencode').chat() +end, { desc = "Open OpenCode Chat" }) +``` + ### ✍️ Ask — `require("opencode").ask()` Input a prompt for `opencode`. diff --git a/ftplugin/opencode_chat.lua b/ftplugin/opencode_chat.lua new file mode 100644 index 00000000..f8e58aaa --- /dev/null +++ b/ftplugin/opencode_chat.lua @@ -0,0 +1,31 @@ +-- Filetype plugin for opencode_chat buffers +-- Provides syntax highlighting for chat messages + +-- Enable markdown-like syntax for code blocks +vim.bo.commentstring = "" + +-- Set up basic syntax highlighting +vim.cmd([[ + syntax match OpencodeHeaderUser "^### You$" + syntax match OpencodeHeaderAssistant "^### Assistant$" + syntax match OpencodeSeparator "^─\+$" + syntax match OpencodeTypingIndicator "^▋$" + + highlight default link OpencodeHeaderUser Title + highlight default link OpencodeHeaderAssistant Special + highlight default link OpencodeSeparator Comment + highlight default link OpencodeTypingIndicator WarningMsg +]]) + +-- Enable treesitter markdown highlighting if available +local ok, ts_highlight = pcall(require, "vim.treesitter.highlighter") +if ok then + local ok_ts = pcall(vim.treesitter.start, vim.api.nvim_get_current_buf(), "markdown") + if not ok_ts then + -- Fallback to basic markdown syntax + vim.cmd("runtime! syntax/markdown.vim") + end +else + -- Fallback to basic markdown syntax + vim.cmd("runtime! syntax/markdown.vim") +end diff --git a/lua/opencode.lua b/lua/opencode.lua index 1e6880b1..dc30da95 100644 --- a/lua/opencode.lua +++ b/lua/opencode.lua @@ -14,4 +14,6 @@ M.stop = require("opencode.provider").stop M.statusline = require("opencode.status").statusline +M.chat = require("opencode.ui.chat_init").start_chat + return M diff --git a/lua/opencode/ui/chat.lua b/lua/opencode/ui/chat.lua new file mode 100644 index 00000000..ec0948f1 --- /dev/null +++ b/lua/opencode/ui/chat.lua @@ -0,0 +1,355 @@ +---Custom chat frontend for opencode.nvim +local M = {} + +---@class opencode.ui.chat.State +---@field bufnr number +---@field winid number +---@field session_id string|nil +---@field messages table[] +---@field port number|nil +---@field streaming_message_index number|nil + +---@type opencode.ui.chat.State|nil +M.state = nil + +---Create a new chat window +---@param opts? { width?: number, height?: number } +---@return opencode.ui.chat.State +function M.open(opts) + opts = opts or {} + + -- Close existing chat window if open + if M.state then + M.close() + end + + -- Create buffer + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_option(bufnr, "filetype", "opencode_chat") + vim.api.nvim_buf_set_option(bufnr, "buftype", "nofile") + vim.api.nvim_buf_set_option(bufnr, "swapfile", false) + vim.api.nvim_buf_set_option(bufnr, "modifiable", false) + vim.api.nvim_buf_set_option(bufnr, "bufhidden", "wipe") + + -- Create floating window + local width = opts.width or math.floor(vim.o.columns * 0.6) + local height = opts.height or math.floor(vim.o.lines * 0.7) + + local winid = vim.api.nvim_open_win(bufnr, true, { + relative = "editor", + width = width, + height = height, + col = math.floor((vim.o.columns - width) / 2), + row = math.floor((vim.o.lines - height) / 2), + style = "minimal", + border = "rounded", + title = " OpenCode Chat ", + title_pos = "center", + }) + + -- Set window options + vim.api.nvim_win_set_option(winid, "wrap", true) + vim.api.nvim_win_set_option(winid, "linebreak", true) + vim.api.nvim_win_set_option(winid, "cursorline", true) + + M.state = { + bufnr = bufnr, + winid = winid, + session_id = nil, + messages = {}, + port = nil, + streaming_message_index = nil, + } + + -- Setup keymaps + M.setup_keymaps(bufnr) + + return M.state +end + +---Setup buffer keymaps +---@param bufnr number +function M.setup_keymaps(bufnr) + local opts = { noremap = true, silent = true, buffer = bufnr } + + -- Close window + vim.keymap.set("n", "q", function() + M.close() + end, vim.tbl_extend("force", opts, { desc = "Close chat" })) + vim.keymap.set("n", "", function() + M.close() + end, vim.tbl_extend("force", opts, { desc = "Close chat" })) + + -- Send prompt + vim.keymap.set("n", "i", function() + M.prompt_input() + end, vim.tbl_extend("force", opts, { desc = "Send message" })) + vim.keymap.set("n", "a", function() + M.prompt_input() + end, vim.tbl_extend("force", opts, { desc = "Send message" })) + + -- Navigate messages + vim.keymap.set("n", "j", "j", opts) + vim.keymap.set("n", "k", "k", opts) + vim.keymap.set("n", "gg", "gg", opts) + vim.keymap.set("n", "G", "G", opts) + + -- Copy message + vim.keymap.set("n", "yy", function() + M.yank_current_message() + end, vim.tbl_extend("force", opts, { desc = "Yank current message" })) + + -- New session + vim.keymap.set("n", "n", function() + M.new_session() + end, vim.tbl_extend("force", opts, { desc = "New session" })) + + -- Interrupt + vim.keymap.set("n", "", function() + M.interrupt() + end, vim.tbl_extend("force", opts, { desc = "Interrupt" })) +end + +---Close chat window +function M.close() + if M.state then + if vim.api.nvim_win_is_valid(M.state.winid) then + vim.api.nvim_win_close(M.state.winid, true) + end + if vim.api.nvim_buf_is_valid(M.state.bufnr) then + vim.api.nvim_buf_delete(M.state.bufnr, { force = true }) + end + M.state = nil + end +end + +---Render messages to buffer +function M.render() + if not M.state or not vim.api.nvim_buf_is_valid(M.state.bufnr) then + return + end + + local lines = {} + local highlights = {} + + for i, msg in ipairs(M.state.messages) do + -- Add separator + if i > 1 then + table.insert(lines, "") + table.insert(lines, string.rep("─", 80)) + table.insert(lines, "") + end + + -- Add role header + local role = msg.role == "user" and "You" or "Assistant" + local header = string.format("### %s", role) + local header_line = #lines + table.insert(lines, header) + table.insert(lines, "") + + -- Add highlight for header + table.insert(highlights, { + line = header_line, + col_start = 0, + col_end = #header, + hl_group = msg.role == "user" and "Title" or "Special", + }) + + -- Add message content + if msg.text then + local content_lines = vim.split(msg.text, "\n") + for _, line in ipairs(content_lines) do + table.insert(lines, line) + end + end + + -- Show typing indicator for streaming messages + if msg.streaming and not msg.complete then + table.insert(lines, "") + table.insert(lines, "▋") -- Typing indicator + end + end + + -- Update buffer + vim.api.nvim_buf_set_option(M.state.bufnr, "modifiable", true) + vim.api.nvim_buf_set_lines(M.state.bufnr, 0, -1, false, lines) + vim.api.nvim_buf_set_option(M.state.bufnr, "modifiable", false) + + -- Apply highlights + local ns_id = vim.api.nvim_create_namespace("opencode_chat") + vim.api.nvim_buf_clear_namespace(M.state.bufnr, ns_id, 0, -1) + for _, hl in ipairs(highlights) do + vim.api.nvim_buf_add_highlight(M.state.bufnr, ns_id, hl.hl_group, hl.line, hl.col_start, hl.col_end) + end + + -- Scroll to bottom + if vim.api.nvim_win_is_valid(M.state.winid) then + vim.api.nvim_win_set_cursor(M.state.winid, { #lines, 0 }) + end +end + +---Prompt for user input +function M.prompt_input() + if not M.state or not M.state.port or not M.state.session_id then + vim.notify("No active session", vim.log.levels.ERROR, { title = "opencode" }) + return + end + + vim.ui.input({ prompt = "Message: " }, function(input) + if input and input ~= "" then + M.send_message(input) + end + end) +end + +---Send a message +---@param text string +function M.send_message(text) + if not M.state or not M.state.port or not M.state.session_id then + vim.notify("No active session", vim.log.levels.ERROR, { title = "opencode" }) + return + end + + -- Add user message to UI immediately + table.insert(M.state.messages, { + role = "user", + text = text, + timestamp = os.time(), + }) + M.render() + + -- Add placeholder for assistant response + table.insert(M.state.messages, { + role = "assistant", + text = "", + streaming = true, + complete = false, + }) + M.state.streaming_message_index = #M.state.messages + M.render() + + -- Send to backend + local client = require("opencode.cli.client") + + -- TODO: Make provider and model configurable + client.send_message(text, M.state.session_id, M.state.port, "anthropic", "claude-3-5-sonnet-20241022", function() + -- Response will come via SSE events + end) +end + +---Add or update a message +---@param message table +function M.add_message(message) + if not M.state then + return + end + + -- Update last assistant message if streaming + if message.role == "assistant" and M.state.streaming_message_index then + local last = M.state.messages[M.state.streaming_message_index] + if last and last.role == "assistant" and last.streaming then + last.text = message.text or last.text or "" + if message.complete then + last.complete = true + last.streaming = false + M.state.streaming_message_index = nil + end + M.render() + return + end + end + + -- Otherwise add new message + table.insert(M.state.messages, message) + M.render() +end + +---Yank the current message under cursor +function M.yank_current_message() + if not M.state then + return + end + + -- Find which message the cursor is on + local cursor_line = vim.api.nvim_win_get_cursor(M.state.winid)[1] + local current_line = 0 + + for _, msg in ipairs(M.state.messages) do + -- Account for separator and header + if current_line > 0 then + current_line = current_line + 3 -- blank, separator, blank + end + current_line = current_line + 2 -- header + blank + + local content_lines = vim.split(msg.text or "", "\n") + local msg_end = current_line + #content_lines + + if cursor_line >= current_line and cursor_line <= msg_end then + -- Found the message, yank it + vim.fn.setreg('"', msg.text or "") + vim.notify("Message yanked to clipboard", vim.log.levels.INFO, { title = "opencode" }) + return + end + + current_line = msg_end + end +end + +---Start a new session +function M.new_session() + if not M.state or not M.state.port then + vim.notify("No connection to opencode", vim.log.levels.ERROR, { title = "opencode" }) + return + end + + -- Clear messages + M.state.messages = {} + M.state.session_id = nil + M.state.streaming_message_index = nil + M.render() + + -- Create new session + local client = require("opencode.cli.client") + client.tui_execute_command("session.new", M.state.port, function() + -- Session ID will be set via SSE event + vim.notify("New session started", vim.log.levels.INFO, { title = "opencode" }) + end) +end + +---Interrupt the current session +function M.interrupt() + if not M.state or not M.state.port then + vim.notify("No connection to opencode", vim.log.levels.ERROR, { title = "opencode" }) + return + end + + local client = require("opencode.cli.client") + client.tui_execute_command("session.interrupt", M.state.port, function() + if M.state and M.state.streaming_message_index then + local msg = M.state.messages[M.state.streaming_message_index] + if msg then + msg.complete = true + msg.streaming = false + end + M.state.streaming_message_index = nil + M.render() + end + vim.notify("Session interrupted", vim.log.levels.INFO, { title = "opencode" }) + end) +end + +---Set the session ID +---@param session_id string +function M.set_session_id(session_id) + if M.state then + M.state.session_id = session_id + end +end + +---Get the current state +---@return opencode.ui.chat.State|nil +function M.get_state() + return M.state +end + +return M diff --git a/lua/opencode/ui/chat_events.lua b/lua/opencode/ui/chat_events.lua new file mode 100644 index 00000000..a2a35fd4 --- /dev/null +++ b/lua/opencode/ui/chat_events.lua @@ -0,0 +1,109 @@ +---Event handler for custom chat frontend +local M = {} + +---@type number|nil +local sse_job_id = nil + +---Subscribe to opencode events and update chat UI +---@param port number +function M.subscribe(port) + local chat = require("opencode.ui.chat") + + -- Unsubscribe from previous if any + M.unsubscribe() + + -- Subscribe to SSE events + sse_job_id = require("opencode.cli.client").call(port, "/event", "GET", nil, function(event) + -- Only process events if chat window is still open + local state = chat.get_state() + if not state then + M.unsubscribe() + return + end + + -- Handle different event types + if event.type == "message.delta" then + -- Streaming message chunk + local delta = event.properties and event.properties.delta or "" + local current_msg = state.messages[state.streaming_message_index] + if current_msg then + current_msg.text = (current_msg.text or "") .. delta + chat.render() + end + elseif event.type == "message.created" or event.type == "message.updated" then + -- Complete message + local msg = event.properties and event.properties.message + if msg and msg.role == "assistant" then + -- Check if we have a streaming message to update + if state.streaming_message_index then + local current_msg = state.messages[state.streaming_message_index] + if current_msg then + current_msg.text = msg.text or current_msg.text or "" + current_msg.complete = true + current_msg.streaming = false + state.streaming_message_index = nil + chat.render() + end + else + -- Add as new message + chat.add_message({ + role = msg.role or "assistant", + text = msg.text or "", + streaming = false, + complete = true, + }) + end + end + elseif event.type == "session.created" or event.type == "session.switched" then + -- New session started or switched + local session = event.properties and event.properties.session + if session and session.id then + chat.set_session_id(session.id) + -- Add a system message to indicate new session + chat.add_message({ + role = "system", + text = "Session started: " .. session.id, + streaming = false, + complete = true, + }) + end + elseif event.type == "session.idle" then + -- Session finished responding + if state.streaming_message_index then + local msg = state.messages[state.streaming_message_index] + if msg then + msg.complete = true + msg.streaming = false + end + state.streaming_message_index = nil + chat.render() + end + elseif event.type == "error" then + -- Handle errors + local error_msg = event.properties and event.properties.message or "Unknown error" + vim.notify("OpenCode error: " .. error_msg, vim.log.levels.ERROR, { title = "opencode" }) + + -- Mark streaming message as complete if error occurred + if state.streaming_message_index then + local msg = state.messages[state.streaming_message_index] + if msg then + msg.text = (msg.text or "") .. "\n\n[Error: " .. error_msg .. "]" + msg.complete = true + msg.streaming = false + end + state.streaming_message_index = nil + chat.render() + end + end + end) +end + +---Unsubscribe from SSE events +function M.unsubscribe() + if sse_job_id then + vim.fn.jobstop(sse_job_id) + sse_job_id = nil + end +end + +return M diff --git a/lua/opencode/ui/chat_init.lua b/lua/opencode/ui/chat_init.lua new file mode 100644 index 00000000..860b5b71 --- /dev/null +++ b/lua/opencode/ui/chat_init.lua @@ -0,0 +1,44 @@ +---Main entry point for custom chat frontend +local M = {} + +---Start a new chat session with custom UI +---@param opts? { width?: number, height?: number } +function M.start_chat(opts) + -- Get or start opencode server + require("opencode.cli.server") + .get_port(true) + :next(function(port) + -- Open chat window + local chat = require("opencode.ui.chat") + local state = chat.open(opts) + + -- Store port + state.port = port + + -- Subscribe to events first + require("opencode.ui.chat_events").subscribe(port) + + -- Create new session via TUI command + local client = require("opencode.cli.client") + client.tui_execute_command("session.new", port, function() + -- Session will be set via SSE event + end) + + -- Show welcome message + vim.schedule(function() + if chat.get_state() then + chat.add_message({ + role = "assistant", + text = "Chat session starting... Type 'i' or 'a' to send a message.\n\nKeybindings:\n i/a - Send message\n n - New session\n q/ - Close\n yy - Yank message\n - Interrupt", + streaming = false, + complete = true, + }) + end + end) + end) + :catch(function(err) + vim.notify("Failed to start opencode: " .. err, vim.log.levels.ERROR, { title = "opencode" }) + end) +end + +return M From 5812b3c638540ae20b923d312b0a8d4a111433c1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:42:58 +0000 Subject: [PATCH 3/9] Fix deprecated APIs and add configurable provider/model settings Co-authored-by: verlihirsh <6280012+verlihirsh@users.noreply.github.com> --- README.md | 10 +++++++++ lua/opencode/config.lua | 15 +++++++++++++ lua/opencode/ui/chat.lua | 47 +++++++++++++++++++++++++--------------- 3 files changed, 55 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index c13ce9bb..e0618567 100644 --- a/README.md +++ b/README.md @@ -267,6 +267,16 @@ Open a custom Neovim frontend for `opencode` with a floating window chat interfa #### Example Setup ```lua +-- Configure chat options (optional) +vim.g.opencode_opts = { + chat = { + provider_id = "anthropic", -- AI provider (default: "anthropic") + model_id = "claude-3-5-sonnet-20241022", -- AI model (default: "claude-3-5-sonnet-20241022") + width = 0.6, -- Window width as fraction of editor width (default: 0.6) + height = 0.7, -- Window height as fraction of editor height (default: 0.7) + } +} + -- Add to your keymaps vim.keymap.set('n', 'oc', function() require('opencode').chat() diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 259020b1..3c20aab4 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -32,12 +32,21 @@ vim.g.opencode_opts = vim.g.opencode_opts ---Supports [`snacks.picker`](https://github.com/folke/snacks.nvim/blob/main/docs/picker.md). ---@field select? opencode.select.Opts --- +---Options for `chat()`. +---@field chat? opencode.chat.Opts +--- ---Options for `opencode` event handling. ---@field events? opencode.events.Opts --- ---Provide an integrated `opencode` when one is not found. ---@field provider? opencode.Provider|opencode.provider.Opts +---@class opencode.chat.Opts +---@field provider_id? string AI provider to use (default: "anthropic") +---@field model_id? string AI model to use (default: "claude-3-5-sonnet-20241022") +---@field width? number Width of chat window as fraction of editor width (default: 0.6) +---@field height? number Height of chat window as fraction of editor height (default: 0.7) + ---@class opencode.Prompt : opencode.api.prompt.Opts ---@field prompt string The prompt to send to `opencode`. ---@field ask? boolean Call `ask(prompt)` instead of `prompt(prompt)`. Useful for prompts that expect additional user input. @@ -105,6 +114,12 @@ local defaults = { }, }, }, + chat = { + provider_id = "anthropic", + model_id = "claude-3-5-sonnet-20241022", + width = 0.6, + height = 0.7, + }, events = { enabled = true, reload = true, diff --git a/lua/opencode/ui/chat.lua b/lua/opencode/ui/chat.lua index ec0948f1..b5785b4c 100644 --- a/lua/opencode/ui/chat.lua +++ b/lua/opencode/ui/chat.lua @@ -8,12 +8,14 @@ local M = {} ---@field messages table[] ---@field port number|nil ---@field streaming_message_index number|nil +---@field provider_id string +---@field model_id string ---@type opencode.ui.chat.State|nil M.state = nil ---Create a new chat window ----@param opts? { width?: number, height?: number } +---@param opts? { width?: number, height?: number, provider_id?: string, model_id?: string } ---@return opencode.ui.chat.State function M.open(opts) opts = opts or {} @@ -23,17 +25,20 @@ function M.open(opts) M.close() end + -- Get config + local config = require("opencode.config").opts.chat or {} + -- Create buffer local bufnr = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_option(bufnr, "filetype", "opencode_chat") - vim.api.nvim_buf_set_option(bufnr, "buftype", "nofile") - vim.api.nvim_buf_set_option(bufnr, "swapfile", false) - vim.api.nvim_buf_set_option(bufnr, "modifiable", false) - vim.api.nvim_buf_set_option(bufnr, "bufhidden", "wipe") + vim.api.nvim_set_option_value("filetype", "opencode_chat", { buf = bufnr }) + vim.api.nvim_set_option_value("buftype", "nofile", { buf = bufnr }) + vim.api.nvim_set_option_value("swapfile", false, { buf = bufnr }) + vim.api.nvim_set_option_value("modifiable", false, { buf = bufnr }) + vim.api.nvim_set_option_value("bufhidden", "wipe", { buf = bufnr }) -- Create floating window - local width = opts.width or math.floor(vim.o.columns * 0.6) - local height = opts.height or math.floor(vim.o.lines * 0.7) + local width = opts.width or math.floor(vim.o.columns * (config.width or 0.6)) + local height = opts.height or math.floor(vim.o.lines * (config.height or 0.7)) local winid = vim.api.nvim_open_win(bufnr, true, { relative = "editor", @@ -48,9 +53,9 @@ function M.open(opts) }) -- Set window options - vim.api.nvim_win_set_option(winid, "wrap", true) - vim.api.nvim_win_set_option(winid, "linebreak", true) - vim.api.nvim_win_set_option(winid, "cursorline", true) + vim.api.nvim_set_option_value("wrap", true, { win = winid }) + vim.api.nvim_set_option_value("linebreak", true, { win = winid }) + vim.api.nvim_set_option_value("cursorline", true, { win = winid }) M.state = { bufnr = bufnr, @@ -59,6 +64,8 @@ function M.open(opts) messages = {}, port = nil, streaming_message_index = nil, + provider_id = opts.provider_id or config.provider_id or "anthropic", + model_id = opts.model_id or config.model_id or "claude-3-5-sonnet-20241022", } -- Setup keymaps @@ -171,9 +178,9 @@ function M.render() end -- Update buffer - vim.api.nvim_buf_set_option(M.state.bufnr, "modifiable", true) + vim.api.nvim_set_option_value("modifiable", true, { buf = M.state.bufnr }) vim.api.nvim_buf_set_lines(M.state.bufnr, 0, -1, false, lines) - vim.api.nvim_buf_set_option(M.state.bufnr, "modifiable", false) + vim.api.nvim_set_option_value("modifiable", false, { buf = M.state.bufnr }) -- Apply highlights local ns_id = vim.api.nvim_create_namespace("opencode_chat") @@ -231,10 +238,16 @@ function M.send_message(text) -- Send to backend local client = require("opencode.cli.client") - -- TODO: Make provider and model configurable - client.send_message(text, M.state.session_id, M.state.port, "anthropic", "claude-3-5-sonnet-20241022", function() - -- Response will come via SSE events - end) + client.send_message( + text, + M.state.session_id, + M.state.port, + M.state.provider_id, + M.state.model_id, + function() + -- Response will come via SSE events + end + ) end ---Add or update a message From 93fbc7f500d575af3726195c22f449432f9e456e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:43:46 +0000 Subject: [PATCH 4/9] Fix code formatting with stylua --- lua/opencode/ui/chat.lua | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/lua/opencode/ui/chat.lua b/lua/opencode/ui/chat.lua index b5785b4c..be648bc5 100644 --- a/lua/opencode/ui/chat.lua +++ b/lua/opencode/ui/chat.lua @@ -238,16 +238,9 @@ function M.send_message(text) -- Send to backend local client = require("opencode.cli.client") - client.send_message( - text, - M.state.session_id, - M.state.port, - M.state.provider_id, - M.state.model_id, - function() - -- Response will come via SSE events - end - ) + client.send_message(text, M.state.session_id, M.state.port, M.state.provider_id, M.state.model_id, function() + -- Response will come via SSE events + end) end ---Add or update a message From 01f709f71d5e090e14506882a29d17c7cc1f4130 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Jan 2026 13:46:28 +0000 Subject: [PATCH 5/9] Use existing SSE management infrastructure from client module Co-authored-by: verlihirsh <6280012+verlihirsh@users.noreply.github.com> --- lua/opencode/ui/chat_events.lua | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/lua/opencode/ui/chat_events.lua b/lua/opencode/ui/chat_events.lua index a2a35fd4..bb20e967 100644 --- a/lua/opencode/ui/chat_events.lua +++ b/lua/opencode/ui/chat_events.lua @@ -1,19 +1,13 @@ ---Event handler for custom chat frontend local M = {} ----@type number|nil -local sse_job_id = nil - ---Subscribe to opencode events and update chat UI ---@param port number function M.subscribe(port) local chat = require("opencode.ui.chat") - -- Unsubscribe from previous if any - M.unsubscribe() - - -- Subscribe to SSE events - sse_job_id = require("opencode.cli.client").call(port, "/event", "GET", nil, function(event) + -- Subscribe to SSE events using the client's built-in management + require("opencode.cli.client").sse_subscribe(port, function(event) -- Only process events if chat window is still open local state = chat.get_state() if not state then @@ -100,10 +94,8 @@ end ---Unsubscribe from SSE events function M.unsubscribe() - if sse_job_id then - vim.fn.jobstop(sse_job_id) - sse_job_id = nil - end + -- Use the client's built-in SSE unsubscribe + require("opencode.cli.client").sse_unsubscribe() end return M From 7f2dc0d60c3f6718e3bf180032d79c97da5816b9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:00:31 +0000 Subject: [PATCH 6/9] Add frontend selection, default keymaps, and auto-setup in config Co-authored-by: verlihirsh <6280012+verlihirsh@users.noreply.github.com> --- README.md | 45 +++++++++++++++++++++++++++------------- lua/opencode/config.lua | 19 +++++++++++++++++ lua/opencode/ui/chat.lua | 37 +++++++++++++++++++-------------- plugin/chat.lua | 24 +++++++++++++++++++++ 4 files changed, 96 insertions(+), 29 deletions(-) create mode 100644 plugin/chat.lua diff --git a/README.md b/README.md index e0618567..6cadeaed 100644 --- a/README.md +++ b/README.md @@ -251,38 +251,55 @@ Open a custom Neovim frontend for `opencode` with a floating window chat interfa - **Vim keybindings** — navigate with hjkl, yank messages, and more - **Session management** — create new sessions, interrupt responses - **Markdown rendering** — syntax highlighting for code blocks +- **Configurable** — customize keymaps, provider, model, and window size -#### Keybindings - -| Key | Action | -| --- | ------ | -| `i` or `a` | Send a message | -| `n` | Start a new session | -| `q` or `` | Close chat window | -| `yy` | Yank current message to clipboard | -| `` | Interrupt current response | -| `j`/`k` | Navigate up/down | -| `gg`/`G` | Jump to top/bottom | +#### Configuration -#### Example Setup +Enable the chat frontend and configure it in your `vim.g.opencode_opts`: ```lua --- Configure chat options (optional) vim.g.opencode_opts = { chat = { + enabled = true, -- Enable custom chat UI instead of terminal TUI (default: false) provider_id = "anthropic", -- AI provider (default: "anthropic") model_id = "claude-3-5-sonnet-20241022", -- AI model (default: "claude-3-5-sonnet-20241022") width = 0.6, -- Window width as fraction of editor width (default: 0.6) height = 0.7, -- Window height as fraction of editor height (default: 0.7) + keymaps = { + open = "oc", -- Keymap to open chat (default: "oc") + send = { "i", "a" }, -- Keymaps to send message (default: {"i", "a"}) + close = { "q", "" }, -- Keymaps to close chat (default: {"q", ""}) + new_session = "n", -- Keymap for new session (default: "n") + interrupt = "", -- Keymap to interrupt (default: "") + yank = "yy", -- Keymap to yank message (default: "yy") + } } } +``` + +When `enabled = true`, the global keymap will be automatically set up. You can also manually call the chat function: --- Add to your keymaps +```lua vim.keymap.set('n', 'oc', function() require('opencode').chat() end, { desc = "Open OpenCode Chat" }) ``` +#### Keybindings + +Default keybindings in the chat window (all configurable): + +| Key | Action | +| --- | ------ | +| `oc` | Open chat window (global) | +| `i` or `a` | Send a message | +| `n` | Start a new session | +| `q` or `` | Close chat window | +| `yy` | Yank current message to clipboard | +| `` | Interrupt current response | +| `j`/`k` | Navigate up/down | +| `gg`/`G` | Jump to top/bottom | + ### ✍️ Ask — `require("opencode").ask()` Input a prompt for `opencode`. diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 3c20aab4..74669f77 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -42,10 +42,20 @@ vim.g.opencode_opts = vim.g.opencode_opts ---@field provider? opencode.Provider|opencode.provider.Opts ---@class opencode.chat.Opts +---@field enabled? boolean Enable custom chat UI instead of terminal TUI (default: false) ---@field provider_id? string AI provider to use (default: "anthropic") ---@field model_id? string AI model to use (default: "claude-3-5-sonnet-20241022") ---@field width? number Width of chat window as fraction of editor width (default: 0.6) ---@field height? number Height of chat window as fraction of editor height (default: 0.7) +---@field keymaps? opencode.chat.Keymaps Keymaps for chat window + +---@class opencode.chat.Keymaps +---@field open? string|string[] Keymap(s) to open chat window (default: "oc") +---@field send? string|string[] Keymap(s) to send message in chat (default: {"i", "a"}) +---@field close? string|string[] Keymap(s) to close chat window (default: {"q", ""}) +---@field new_session? string Keymap to start new session (default: "n") +---@field interrupt? string Keymap to interrupt response (default: "") +---@field yank? string Keymap to yank current message (default: "yy") ---@class opencode.Prompt : opencode.api.prompt.Opts ---@field prompt string The prompt to send to `opencode`. @@ -115,10 +125,19 @@ local defaults = { }, }, chat = { + enabled = false, provider_id = "anthropic", model_id = "claude-3-5-sonnet-20241022", width = 0.6, height = 0.7, + keymaps = { + open = "oc", + send = { "i", "a" }, + close = { "q", "" }, + new_session = "n", + interrupt = "", + yank = "yy", + }, }, events = { enabled = true, diff --git a/lua/opencode/ui/chat.lua b/lua/opencode/ui/chat.lua index be648bc5..5c3bd625 100644 --- a/lua/opencode/ui/chat.lua +++ b/lua/opencode/ui/chat.lua @@ -77,23 +77,30 @@ end ---Setup buffer keymaps ---@param bufnr number function M.setup_keymaps(bufnr) + local config = require("opencode.config").opts.chat or {} + local keymaps = config.keymaps or {} local opts = { noremap = true, silent = true, buffer = bufnr } + -- Helper function to set keymaps that might be arrays + local function set_keymap(keys, callback, desc) + if type(keys) == "string" then + vim.keymap.set("n", keys, callback, vim.tbl_extend("force", opts, { desc = desc })) + elseif type(keys) == "table" then + for _, key in ipairs(keys) do + vim.keymap.set("n", key, callback, vim.tbl_extend("force", opts, { desc = desc })) + end + end + end + -- Close window - vim.keymap.set("n", "q", function() - M.close() - end, vim.tbl_extend("force", opts, { desc = "Close chat" })) - vim.keymap.set("n", "", function() + set_keymap(keymaps.close or { "q", "" }, function() M.close() - end, vim.tbl_extend("force", opts, { desc = "Close chat" })) + end, "Close chat") -- Send prompt - vim.keymap.set("n", "i", function() - M.prompt_input() - end, vim.tbl_extend("force", opts, { desc = "Send message" })) - vim.keymap.set("n", "a", function() + set_keymap(keymaps.send or { "i", "a" }, function() M.prompt_input() - end, vim.tbl_extend("force", opts, { desc = "Send message" })) + end, "Send message") -- Navigate messages vim.keymap.set("n", "j", "j", opts) @@ -102,17 +109,17 @@ function M.setup_keymaps(bufnr) vim.keymap.set("n", "G", "G", opts) -- Copy message - vim.keymap.set("n", "yy", function() + set_keymap(keymaps.yank or "yy", function() M.yank_current_message() - end, vim.tbl_extend("force", opts, { desc = "Yank current message" })) + end, "Yank current message") -- New session - vim.keymap.set("n", "n", function() + set_keymap(keymaps.new_session or "n", function() M.new_session() - end, vim.tbl_extend("force", opts, { desc = "New session" })) + end, "New session") -- Interrupt - vim.keymap.set("n", "", function() + set_keymap(keymaps.interrupt or "", function() M.interrupt() end, vim.tbl_extend("force", opts, { desc = "Interrupt" })) end diff --git a/plugin/chat.lua b/plugin/chat.lua new file mode 100644 index 00000000..c0ceb3b4 --- /dev/null +++ b/plugin/chat.lua @@ -0,0 +1,24 @@ +-- Setup global keymaps for chat functionality +local config = require("opencode.config").opts.chat or {} + +-- Only setup global keymap if chat is enabled and keymap is configured +if config.enabled and config.keymaps and config.keymaps.open then + local open_keys = config.keymaps.open + + -- Helper function to set keymaps that might be arrays + local function set_keymap(keys) + if type(keys) == "string" then + vim.keymap.set("n", keys, function() + require("opencode").chat() + end, { desc = "Open OpenCode Chat", silent = true }) + elseif type(keys) == "table" then + for _, key in ipairs(keys) do + vim.keymap.set("n", key, function() + require("opencode").chat() + end, { desc = "Open OpenCode Chat", silent = true }) + end + end + end + + set_keymap(open_keys) +end From ea848ff5962f1cb0df8f296d98a9ec62b73e95bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:19:16 +0000 Subject: [PATCH 7/9] Address code review feedback: add bounds checking, fix bugs, improve validation Co-authored-by: verlihirsh <6280012+verlihirsh@users.noreply.github.com> --- ftplugin/opencode_chat.lua | 6 +- lua/opencode/ui/chat.lua | 100 +++++++++++++++++++++++--------- lua/opencode/ui/chat_events.lua | 17 +++--- lua/opencode/ui/chat_init.lua | 29 ++++++++- 4 files changed, 114 insertions(+), 38 deletions(-) diff --git a/ftplugin/opencode_chat.lua b/ftplugin/opencode_chat.lua index f8e58aaa..6016c585 100644 --- a/ftplugin/opencode_chat.lua +++ b/ftplugin/opencode_chat.lua @@ -8,11 +8,13 @@ vim.bo.commentstring = "" vim.cmd([[ syntax match OpencodeHeaderUser "^### You$" syntax match OpencodeHeaderAssistant "^### Assistant$" + syntax match OpencodeHeaderSystem "^### System$" syntax match OpencodeSeparator "^─\+$" syntax match OpencodeTypingIndicator "^▋$" highlight default link OpencodeHeaderUser Title highlight default link OpencodeHeaderAssistant Special + highlight default link OpencodeHeaderSystem Comment highlight default link OpencodeSeparator Comment highlight default link OpencodeTypingIndicator WarningMsg ]]) @@ -20,8 +22,8 @@ vim.cmd([[ -- Enable treesitter markdown highlighting if available local ok, ts_highlight = pcall(require, "vim.treesitter.highlighter") if ok then - local ok_ts = pcall(vim.treesitter.start, vim.api.nvim_get_current_buf(), "markdown") - if not ok_ts then + ok = pcall(vim.treesitter.start, vim.api.nvim_get_current_buf(), "markdown") + if not ok then -- Fallback to basic markdown syntax vim.cmd("runtime! syntax/markdown.vim") end diff --git a/lua/opencode/ui/chat.lua b/lua/opencode/ui/chat.lua index 5c3bd625..5ab2c843 100644 --- a/lua/opencode/ui/chat.lua +++ b/lua/opencode/ui/chat.lua @@ -37,8 +37,28 @@ function M.open(opts) vim.api.nvim_set_option_value("bufhidden", "wipe", { buf = bufnr }) -- Create floating window - local width = opts.width or math.floor(vim.o.columns * (config.width or 0.6)) - local height = opts.height or math.floor(vim.o.lines * (config.height or 0.7)) + -- Support both fractional (0-1) and absolute pixel values + local width + if opts.width ~= nil then + if opts.width > 0 and opts.width < 1 then + width = math.floor(vim.o.columns * opts.width) + else + width = opts.width + end + else + width = math.floor(vim.o.columns * (config.width or 0.6)) + end + + local height + if opts.height ~= nil then + if opts.height > 0 and opts.height < 1 then + height = math.floor(vim.o.lines * opts.height) + else + height = opts.height + end + else + height = math.floor(vim.o.lines * (config.height or 0.7)) + end local winid = vim.api.nvim_open_win(bufnr, true, { relative = "editor", @@ -102,12 +122,6 @@ function M.setup_keymaps(bufnr) M.prompt_input() end, "Send message") - -- Navigate messages - vim.keymap.set("n", "j", "j", opts) - vim.keymap.set("n", "k", "k", opts) - vim.keymap.set("n", "gg", "gg", opts) - vim.keymap.set("n", "G", "G", opts) - -- Copy message set_keymap(keymaps.yank or "yy", function() M.yank_current_message() @@ -121,7 +135,7 @@ function M.setup_keymaps(bufnr) -- Interrupt set_keymap(keymaps.interrupt or "", function() M.interrupt() - end, vim.tbl_extend("force", opts, { desc = "Interrupt" })) + end, "Interrupt") end ---Close chat window @@ -146,27 +160,46 @@ function M.render() local lines = {} local highlights = {} + -- Get window width for dynamic separator + local win_width = vim.api.nvim_win_is_valid(M.state.winid) and vim.api.nvim_win_get_width(M.state.winid) or 80 + for i, msg in ipairs(M.state.messages) do -- Add separator if i > 1 then table.insert(lines, "") - table.insert(lines, string.rep("─", 80)) + table.insert(lines, string.rep("─", win_width)) table.insert(lines, "") end - -- Add role header - local role = msg.role == "user" and "You" or "Assistant" - local header = string.format("### %s", role) + -- Add role header with proper role handling + local role_label + if msg.role == "user" then + role_label = "You" + elseif msg.role == "system" then + role_label = "System" + else + -- Default to Assistant for assistant role or nil + role_label = "Assistant" + end + local header = string.format("### %s", role_label) local header_line = #lines table.insert(lines, header) table.insert(lines, "") -- Add highlight for header + local hl_group + if msg.role == "user" then + hl_group = "Title" + elseif msg.role == "system" then + hl_group = "Comment" + else + hl_group = "Special" + end table.insert(highlights, { line = header_line, col_start = 0, col_end = #header, - hl_group = msg.role == "user" and "Title" or "Special", + hl_group = hl_group, }) -- Add message content @@ -228,7 +261,6 @@ function M.send_message(text) table.insert(M.state.messages, { role = "user", text = text, - timestamp = os.time(), }) M.render() @@ -257,18 +289,27 @@ function M.add_message(message) return end + -- Validate message has required fields + if not message or not message.role then + vim.notify("Invalid message: missing role", vim.log.levels.WARN, { title = "opencode" }) + return + end + -- Update last assistant message if streaming if message.role == "assistant" and M.state.streaming_message_index then - local last = M.state.messages[M.state.streaming_message_index] - if last and last.role == "assistant" and last.streaming then - last.text = message.text or last.text or "" - if message.complete then - last.complete = true - last.streaming = false - M.state.streaming_message_index = nil + -- Verify index is within bounds + if M.state.streaming_message_index <= #M.state.messages then + local last = M.state.messages[M.state.streaming_message_index] + if last and last.role == "assistant" and last.streaming then + last.text = message.text or last.text or "" + if message.complete then + last.complete = true + last.streaming = false + M.state.streaming_message_index = nil + end + M.render() + return end - M.render() - return end end @@ -339,10 +380,13 @@ function M.interrupt() local client = require("opencode.cli.client") client.tui_execute_command("session.interrupt", M.state.port, function() if M.state and M.state.streaming_message_index then - local msg = M.state.messages[M.state.streaming_message_index] - if msg then - msg.complete = true - msg.streaming = false + -- Verify index is within bounds + if M.state.streaming_message_index <= #M.state.messages then + local msg = M.state.messages[M.state.streaming_message_index] + if msg then + msg.complete = true + msg.streaming = false + end end M.state.streaming_message_index = nil M.render() diff --git a/lua/opencode/ui/chat_events.lua b/lua/opencode/ui/chat_events.lua index bb20e967..2f5fe8ae 100644 --- a/lua/opencode/ui/chat_events.lua +++ b/lua/opencode/ui/chat_events.lua @@ -19,17 +19,20 @@ function M.subscribe(port) if event.type == "message.delta" then -- Streaming message chunk local delta = event.properties and event.properties.delta or "" - local current_msg = state.messages[state.streaming_message_index] - if current_msg then - current_msg.text = (current_msg.text or "") .. delta - chat.render() + -- Verify index is within bounds + if state.streaming_message_index and state.streaming_message_index <= #state.messages then + local current_msg = state.messages[state.streaming_message_index] + if current_msg then + current_msg.text = (current_msg.text or "") .. delta + chat.render() + end end elseif event.type == "message.created" or event.type == "message.updated" then -- Complete message local msg = event.properties and event.properties.message if msg and msg.role == "assistant" then -- Check if we have a streaming message to update - if state.streaming_message_index then + if state.streaming_message_index and state.streaming_message_index <= #state.messages then local current_msg = state.messages[state.streaming_message_index] if current_msg then current_msg.text = msg.text or current_msg.text or "" @@ -63,7 +66,7 @@ function M.subscribe(port) end elseif event.type == "session.idle" then -- Session finished responding - if state.streaming_message_index then + if state.streaming_message_index and state.streaming_message_index <= #state.messages then local msg = state.messages[state.streaming_message_index] if msg then msg.complete = true @@ -78,7 +81,7 @@ function M.subscribe(port) vim.notify("OpenCode error: " .. error_msg, vim.log.levels.ERROR, { title = "opencode" }) -- Mark streaming message as complete if error occurred - if state.streaming_message_index then + if state.streaming_message_index and state.streaming_message_index <= #state.messages then local msg = state.messages[state.streaming_message_index] if msg then msg.text = (msg.text or "") .. "\n\n[Error: " .. error_msg .. "]" diff --git a/lua/opencode/ui/chat_init.lua b/lua/opencode/ui/chat_init.lua index 860b5b71..23bb2790 100644 --- a/lua/opencode/ui/chat_init.lua +++ b/lua/opencode/ui/chat_init.lua @@ -27,9 +27,36 @@ function M.start_chat(opts) -- Show welcome message vim.schedule(function() if chat.get_state() then + local config = require("opencode.config").opts.chat or {} + local keymaps = config.keymaps or {} + + -- Format keymaps for display + local function format_keys(keys) + if type(keys) == "string" then + return keys + elseif type(keys) == "table" then + return table.concat(keys, "/") + end + return "?" + end + + local send_keys = format_keys(keymaps.send or { "i", "a" }) + local new_session_key = format_keys(keymaps.new_session or "n") + local close_keys = format_keys(keymaps.close or { "q", "" }) + local yank_key = format_keys(keymaps.yank or "yy") + local interrupt_key = format_keys(keymaps.interrupt or "") + chat.add_message({ role = "assistant", - text = "Chat session starting... Type 'i' or 'a' to send a message.\n\nKeybindings:\n i/a - Send message\n n - New session\n q/ - Close\n yy - Yank message\n - Interrupt", + text = string.format( + "Chat session starting... Type '%s' to send a message.\n\nKeybindings:\n %s - Send message\n %s - New session\n %s - Close\n %s - Yank message\n %s - Interrupt", + send_keys, + send_keys, + new_session_key, + close_keys, + yank_key, + interrupt_key + ), streaming = false, complete = true, }) From 543a0afc6980eaa307092e162c663d1302cc000d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:32:40 +0000 Subject: [PATCH 8/9] Initial plan From 4b83a10d3c98590ef5adef2c0e77cb5312be62be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 5 Jan 2026 14:36:10 +0000 Subject: [PATCH 9/9] Fix cursor position error when buffer is empty in new_session Co-authored-by: verlihirsh <6280012+verlihirsh@users.noreply.github.com> --- lua/opencode/ui/chat.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/opencode/ui/chat.lua b/lua/opencode/ui/chat.lua index 5ab2c843..4be0de03 100644 --- a/lua/opencode/ui/chat.lua +++ b/lua/opencode/ui/chat.lua @@ -230,7 +230,7 @@ function M.render() end -- Scroll to bottom - if vim.api.nvim_win_is_valid(M.state.winid) then + if vim.api.nvim_win_is_valid(M.state.winid) and #lines > 0 then vim.api.nvim_win_set_cursor(M.state.winid, { #lines, 0 }) end end