diff --git a/README.md b/README.md index 9abcf9b7..6cadeaed 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,64 @@ 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 +- **Configurable** — customize keymaps, provider, model, and window size + +#### Configuration + +Enable the chat frontend and configure it in your `vim.g.opencode_opts`: + +```lua +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: + +```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/ftplugin/opencode_chat.lua b/ftplugin/opencode_chat.lua new file mode 100644 index 00000000..6016c585 --- /dev/null +++ b/ftplugin/opencode_chat.lua @@ -0,0 +1,33 @@ +-- 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 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 +]]) + +-- Enable treesitter markdown highlighting if available +local ok, ts_highlight = pcall(require, "vim.treesitter.highlighter") +if ok 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 +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/config.lua b/lua/opencode/config.lua index 259020b1..74669f77 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -32,12 +32,31 @@ 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 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`. ---@field ask? boolean Call `ask(prompt)` instead of `prompt(prompt)`. Useful for prompts that expect additional user input. @@ -105,6 +124,21 @@ 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, reload = true, diff --git a/lua/opencode/ui/chat.lua b/lua/opencode/ui/chat.lua new file mode 100644 index 00000000..4be0de03 --- /dev/null +++ b/lua/opencode/ui/chat.lua @@ -0,0 +1,412 @@ +---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 +---@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, provider_id?: string, model_id?: string } +---@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 + + -- Get config + local config = require("opencode.config").opts.chat or {} + + -- Create buffer + local bufnr = vim.api.nvim_create_buf(false, true) + 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 + -- 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", + 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_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, + winid = winid, + session_id = nil, + 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 + M.setup_keymaps(bufnr) + + return M.state +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 + set_keymap(keymaps.close or { "q", "" }, function() + M.close() + end, "Close chat") + + -- Send prompt + set_keymap(keymaps.send or { "i", "a" }, function() + M.prompt_input() + end, "Send message") + + -- Copy message + set_keymap(keymaps.yank or "yy", function() + M.yank_current_message() + end, "Yank current message") + + -- New session + set_keymap(keymaps.new_session or "n", function() + M.new_session() + end, "New session") + + -- Interrupt + set_keymap(keymaps.interrupt or "", function() + M.interrupt() + end, "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 = {} + + -- 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("─", win_width)) + table.insert(lines, "") + end + + -- 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 = hl_group, + }) + + -- 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_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_set_option_value("modifiable", false, { buf = M.state.bufnr }) + + -- 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) and #lines > 0 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, + }) + 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") + + 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 +---@param message table +function M.add_message(message) + if not M.state then + 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 + -- 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 + 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 + -- 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() + 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..2f5fe8ae --- /dev/null +++ b/lua/opencode/ui/chat_events.lua @@ -0,0 +1,104 @@ +---Event handler for custom chat frontend +local M = {} + +---Subscribe to opencode events and update chat UI +---@param port number +function M.subscribe(port) + local chat = require("opencode.ui.chat") + + -- 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 + 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 "" + -- 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 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 "" + 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 and state.streaming_message_index <= #state.messages 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 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 .. "]" + 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() + -- Use the client's built-in SSE unsubscribe + require("opencode.cli.client").sse_unsubscribe() +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..23bb2790 --- /dev/null +++ b/lua/opencode/ui/chat_init.lua @@ -0,0 +1,71 @@ +---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 + 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 = 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, + }) + end + end) + end) + :catch(function(err) + vim.notify("Failed to start opencode: " .. err, vim.log.levels.ERROR, { title = "opencode" }) + end) +end + +return M 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