diff --git a/README.md b/README.md
index 1549515c..b967537f 100644
--- a/README.md
+++ b/README.md
@@ -1,10 +1,26 @@
# 🤖 opencode.nvim
+> neovim frontend for opencode - a terminal-based AI coding agent
+
+## Main Features
+
+### Chat Panel
+
-> neovim frontend for opencode - a terminal-based AI coding agent
+### Quick buffer chat (o/) EXPERIMENTAL:
+
+This is an experimental feature that allows you to chat with the AI using the current buffer context. In visual mode, it captures the selected text as context, while in normal mode, it uses the current line. The AI will respond with quick edits to the files that are applied by the plugin.
+
+Don't hesitate to give it a try and provide feedback!
+
+Refer to the [Quick Chat](#-quick-chat) section for more details.
+
+
+

+
@@ -14,11 +30,6 @@
-## 🙏 Acknowledgements
-
-This plugin is a fork of the original [goose.nvim](https://github.com/azorng/goose.nvim) plugin by [azorng](https://github.com/azorng/)
-For git history purposes the original code is copied instead of just forked.
-
## ✨ Description
This plugin provides a bridge between neovim and the [opencode](https://github.com/sst/opencode) AI agent, creating a chat interface while capturing editor context (current file, selections) to enhance your prompts. It maintains persistent sessions tied to your workspace, allowing for continuous conversations with the AI assistant similar to what tools like Cursor AI offer.
@@ -38,6 +49,8 @@ This plugin provides a bridge between neovim and the [opencode](https://github.c
- [Agents](#-agents)
- [User Commands](#user-commands)
- [Contextual Actions for Snapshots](#-contextual-actions-for-snapshots)
+- [Prompt Guard](#-prompt-guard)
+- [Quick Chat](#-quick-chat)
- [Setting up opencode](#-setting-up-opencode)
## ⚠️Caution
@@ -129,6 +142,7 @@ require('opencode').setup({
['opd'] = { 'permission_deny' }, -- Deny permission request once
['ott'] = { 'toggle_tool_output' }, -- Toggle tools output (diffs, cmd output, etc.)
['otr'] = { 'toggle_reasoning_output' }, -- Toggle reasoning output (thinking steps)
+ ['o/'] = { 'quick_chat', mode = { 'n', 'x' } }, -- Open quick chat input with selection context in visual mode or current line context in normal mode
},
input_window = {
[''] = { 'submit_input_prompt', mode = { 'n', 'i' } }, -- Submit prompt (normal mode and insert mode)
@@ -244,21 +258,40 @@ require('opencode').setup({
enabled = true, -- Enable automatic context capturing
cursor_data = {
enabled = false, -- Include cursor position and line content in the context
+ context_lines = 5, -- Number of lines before and after cursor to include in context
},
diagnostics = {
info = false, -- Include diagnostics info in the context (default to false
warn = true, -- Include diagnostics warnings in the context
error = true, -- Include diagnostics errors in the context
+ only_closest = false, -- If true, only diagnostics for cursor/selection
},
current_file = {
enabled = true, -- Include current file path and content in the context
+ show_full_path = true,
+ },
+ files = {
+ enabled = true,
+ show_full_path = true,
},
selection = {
enabled = true, -- Include selected text in the context
},
+ buffer = {
+ enabled = false, -- Disable entire buffer context by default, only used in quick chat
+ },
+ git_diff = {
+ enabled = false,
+ },
},
debug = {
enabled = false, -- Enable debug messages in the output window
+ capture_streamed_events = false,
+ show_ids = true,
+ quick_chat = {
+ keep_session = false, -- Keep quick_chat sessions for inspection, this can pollute your sessions list
+ set_active_session = false,
+ },
},
prompt_guard = nil, -- Optional function that returns boolean to control when prompts can be sent (see Prompt Guard section)
@@ -269,6 +302,11 @@ require('opencode').setup({
on_done_thinking = nil, -- Called when opencode finishes thinking (all jobs complete).
on_permission_requested = nil, -- Called when a permission request is issued.
},
+ quick_chat = {
+ default_model = nil, -- works better with a fast model like gpt-4.1
+ default_agent = 'plan', -- plan ensure no file modifications by default
+ instructions = nil, -- Use built-in instructions if nil
+ },
})
```
@@ -341,62 +379,61 @@ The plugin provides the following actions that can be triggered via keymaps, com
> **Note:** Commands have been restructured into a single `:Opencode` command with subcommands. Legacy `Opencode*` commands (e.g., `:OpencodeOpenInput`) are still available by default but will be removed in a future version. Update your scripts and workflows to use the new nested syntax.
-| Action | Default keymap | Command | API Function |
-| --------------------------------------------------------- | ------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------- |
-| Open opencode. Close if opened | `og` | `:Opencode` | `require('opencode.api').toggle()` |
-| Open input window (current session) | `oi` | `:Opencode open input` | `require('opencode.api').open_input()` |
-| Open input window (new session) | `oI` | `:Opencode open input_new_session` | `require('opencode.api').open_input_new_session()` |
-| Open output window | `oo` | `:Opencode open output` | `require('opencode.api').open_output()` |
-| Create and switch to a named session | - | `:Opencode session new ` | `:Opencode session new ` (user command) |
-| Rename current session | `oR` | `:Opencode session rename ` | `:Opencode session rename ` (user command) |
-| Toggle focus opencode / last window | `ot` | `:Opencode toggle focus` | `require('opencode.api').toggle_focus()` |
-| Close UI windows | `oq` | `:Opencode close` | `require('opencode.api').close()` |
-| Select and load session | `os` | `:Opencode session select` | `require('opencode.api').select_session()` |
-| **Select and load child session** | `oS` | `:Opencode session select_child` | `require('opencode.api').select_child_session()` |
-| Open timeline picker (navigate/undo/redo/fork to message) | `oT` | `:Opencode timeline` | `require('opencode.api').timeline()` |
-| Browse code references from conversation | `gr` (window) | `:Opencode references` / `/references` | `require('opencode.api').references()` |
-| Configure provider and model | `op` | `:Opencode configure provider` | `require('opencode.api').configure_provider()` |
-| Open diff view of changes | `od` | `:Opencode diff open` | `require('opencode.api').diff_open()` |
-| Navigate to next file diff | `o]` | `:Opencode diff next` | `require('opencode.api').diff_next()` |
-| Navigate to previous file diff | `o[` | `:Opencode diff prev` | `require('opencode.api').diff_prev()` |
-| Close diff view tab | `oc` | `:Opencode diff close` | `require('opencode.api').diff_close()` |
-| Revert all file changes since last prompt | `ora` | `:Opencode revert all prompt` | `require('opencode.api').diff_revert_all_last_prompt()` |
-| Revert current file changes last prompt | `ort` | `:Opencode revert this prompt` | `require('opencode.api').diff_revert_this_last_prompt()` |
-| Revert all file changes since last session | `orA` | `:Opencode revert all session` | `require('opencode.api').diff_revert_all_session()` |
-| Revert current file changes last session | `orT` | `:Opencode revert this session` | `require('opencode.api').diff_revert_this_session()` |
-| Revert all files to a specific snapshot | - | `:Opencode revert all_to_snapshot` | `require('opencode.api').diff_revert_all(snapshot_id)` |
-| Revert current file to a specific snapshot | - | `:Opencode revert this_to_snapshot` | `require('opencode.api').diff_revert_this(snapshot_id)` |
-| Restore a file to a restore point | - | `:Opencode restore snapshot_file` | `require('opencode.api').diff_restore_snapshot_file(restore_point_id)` |
-| Restore all files to a restore point | - | `:Opencode restore snapshot_all` | `require('opencode.api').diff_restore_snapshot_all(restore_point_id)` |
-| Initialize/update AGENTS.md file | - | `:Opencode session agents_init` | `require('opencode.api').initialize()` |
-| Run prompt (continue session) [Run opts](#run-opts) | - | `:Opencode run ` | `require('opencode.api').run("prompt", opts)` |
-| Run prompt (new session) [Run opts](#run-opts) | - | `:Opencode run new_session ` | `require('opencode.api').run_new_session("prompt", opts)` |
-| Cancel opencode while it is running | `` | `:Opencode cancel` | `require('opencode.api').cancel()` |
-| Set mode to Build | - | `:Opencode agent build` | `require('opencode.api').agent_build()` |
-| Set mode to Plan | - | `:Opencode agent plan` | `require('opencode.api').agent_plan()` |
-| Select and switch mode/agent | - | `:Opencode agent select` | `require('opencode.api').select_agent()` |
-| Display list of availale mcp servers | - | `:Opencode mcp` | `require('opencode.api').mcp()` |
-| Run user commands | - | `:Opencode run user_command` | `require('opencode.api').run_user_command()` |
-| Share current session and get a link | - | `:Opencode session share` / `/share` | `require('opencode.api').share()` |
-| Unshare current session (disable link) | - | `:Opencode session unshare` / `/unshare` | `require('opencode.api').unshare()` |
-| Compact current session (summarize) | - | `:Opencode session compact` / `/compact` | `require('opencode.api').compact_session()` |
-| Undo last opencode action | - | `:Opencode undo` / `/undo` | `require('opencode.api').undo()` |
-| Redo last opencode action | - | `:Opencode redo` / `/redo` | `require('opencode.api').redo()` |
-| Respond to permission requests (accept once) | `a` (window) / `opa` (global) | `:Opencode permission accept` | `require('opencode.api').permission_accept()` |
-| Respond to permission requests (accept all) | `A` (window) / `opA` (global) | `:Opencode permission accept_all` | `require('opencode.api').permission_accept_all()` |
-| Respond to permission requests (deny) | `d` (window) / `opd` (global) | `:Opencode permission deny` | `require('opencode.api').permission_deny()` |
-| Insert mention (file/ agent) | `@` | - | - |
-| [Pick a file and add to context](#file-mentions) | `~` | - | - |
-| Navigate to next message | `]]` | - | - |
-| Navigate to previous message | `[[` | - | - |
-| Navigate to previous prompt in history | `` | - | `require('opencode.api').prev_history()` |
-| Navigate to next prompt in history | `` | - | `require('opencode.api').next_history()` |
-| Toggle input/output panes | `` | - | - |
-| Swap Opencode pane left/right | `ox` | `:Opencode swap position` | `require('opencode.api').swap_position()` |
-| Toggle tools output (diffs, cmd output, etc.) | `ott` | `:Opencode toggle_tool_output` | `require('opencode.api').toggle_tool_output()` |
-| Toggle reasoning output (thinking steps) | `otr` | `:Opencode toggle_reasoning_output` | `require('opencode.api').toggle_reasoning_output()` |
-
----
+| Action | Default keymap | Command | API Function |
+| ----------------------------------------------------------- | ------------------------------------- | ------------------------------------------- | ---------------------------------------------------------------------- |
+| Open opencode. Close if opened | `og` | `:Opencode` | `require('opencode.api').toggle()` |
+| Open input window (current session) | `oi` | `:Opencode open input` | `require('opencode.api').open_input()` |
+| Open input window (new session) | `oI` | `:Opencode open input_new_session` | `require('opencode.api').open_input_new_session()` |
+| Open output window | `oo` | `:Opencode open output` | `require('opencode.api').open_output()` |
+| Create and switch to a named session | - | `:Opencode session new ` | `:Opencode session new ` (user command) |
+| Rename current session | `oR` | `:Opencode session rename ` | `:Opencode session rename ` (user command) |
+| Toggle focus opencode / last window | `ot` | `:Opencode toggle focus` | `require('opencode.api').toggle_focus()` |
+| Close UI windows | `oq` | `:Opencode close` | `require('opencode.api').close()` |
+| Select and load session | `os` | `:Opencode session select` | `require('opencode.api').select_session()` |
+| **Select and load child session** | `oS` | `:Opencode session select_child` | `require('opencode.api').select_child_session()` |
+| Open timeline picker (navigate/undo/redo/fork to message) | `oT` | `:Opencode timeline` | `require('opencode.api').timeline()` |
+| Browse code references from conversation | `gr` (window) | `:Opencode references` / `/references` | `require('opencode.api').references()` |
+| Configure provider and model | `op` | `:Opencode configure provider` | `require('opencode.api').configure_provider()` |
+| Open diff view of changes | `od` | `:Opencode diff open` | `require('opencode.api').diff_open()` |
+| Navigate to next file diff | `o]` | `:Opencode diff next` | `require('opencode.api').diff_next()` |
+| Navigate to previous file diff | `o[` | `:Opencode diff prev` | `require('opencode.api').diff_prev()` |
+| Close diff view tab | `oc` | `:Opencode diff close` | `require('opencode.api').diff_close()` |
+| Revert all file changes since last prompt | `ora` | `:Opencode revert all prompt` | `require('opencode.api').diff_revert_all_last_prompt()` |
+| Revert current file changes last prompt | `ort` | `:Opencode revert this prompt` | `require('opencode.api').diff_revert_this_last_prompt()` |
+| Revert all file changes since last session | `orA` | `:Opencode revert all session` | `require('opencode.api').diff_revert_all_session()` |
+| Revert current file changes last session | `orT` | `:Opencode revert this session` | `require('opencode.api').diff_revert_this_session()` |
+| Revert all files to a specific snapshot | - | `:Opencode revert all_to_snapshot` | `require('opencode.api').diff_revert_all(snapshot_id)` |
+| Revert current file to a specific snapshot | - | `:Opencode revert this_to_snapshot` | `require('opencode.api').diff_revert_this(snapshot_id)` |
+| Restore a file to a restore point | - | `:Opencode restore snapshot_file` | `require('opencode.api').diff_restore_snapshot_file(restore_point_id)` |
+| Restore all files to a restore point | - | `:Opencode restore snapshot_all` | `require('opencode.api').diff_restore_snapshot_all(restore_point_id)` |
+| Initialize/update AGENTS.md file | - | `:Opencode session agents_init` | `require('opencode.api').initialize()` |
+| Run prompt (continue session) [Run opts](#run-opts) | - | `:Opencode run ` | `require('opencode.api').run("prompt", opts)` |
+| Run prompt (new session) [Run opts](#run-opts) | - | `:Opencode run new_session ` | `require('opencode.api').run_new_session("prompt", opts)` |
+| Cancel opencode while it is running | `` | `:Opencode cancel` | `require('opencode.api').cancel()` |
+| Set mode to Build | - | `:Opencode agent build` | `require('opencode.api').agent_build()` |
+| Set mode to Plan | - | `:Opencode agent plan` | `require('opencode.api').agent_plan()` |
+| Select and switch mode/agent | - | `:Opencode agent select` | `require('opencode.api').select_agent()` |
+| Display list of available mcp servers | - | `:Opencode mcp` | `require('opencode.api').mcp()` |
+| Run user commands | - | `:Opencode run user_command` | `require('opencode.api').run_user_command()` |
+| Share current session and get a link | - | `:Opencode session share` / `/share` | `require('opencode.api').share()` |
+| Unshare current session (disable link) | - | `:Opencode session unshare` / `/unshare` | `require('opencode.api').unshare()` |
+| Compact current session (summarize) | - | `:Opencode session compact` / `/compact` | `require('opencode.api').compact_session()` |
+| Undo last opencode action | - | `:Opencode undo` / `/undo` | `require('opencode.api').undo()` |
+| Redo last opencode action | - | `:Opencode redo` / `/redo` | `require('opencode.api').redo()` |
+| Respond to permission requests (accept once) | `a` (window) / `opa` (global) | `:Opencode permission accept` | `require('opencode.api').permission_accept()` |
+| Respond to permission requests (accept all) | `A` (window) / `opA` (global) | `:Opencode permission accept_all` | `require('opencode.api').permission_accept_all()` |
+| Respond to permission requests (deny) | `d` (window) / `opd` (global) | `:Opencode permission deny` | `require('opencode.api').permission_deny()` |
+| Insert mention (file/ agent) | `@` | - | - |
+| [Pick a file and add to context](#file-mentions) | `~` | - | - |
+| Navigate to next message | `]]` | - | - |
+| Navigate to previous message | `[[` | - | - |
+| Navigate to previous prompt in history | `` | - | `require('opencode.api').prev_history()` |
+| Navigate to next prompt in history | `` | - | `require('opencode.api').next_history()` |
+| Toggle input/output panes | `` | - | - |
+| Swap Opencode pane left/right | `ox` | `:Opencode swap position` | `require('opencode.api').swap_position()` |
+| Toggle tools output (diffs, cmd output, etc.) | `ott` | `:Opencode toggle_tool_output` | `require('opencode.api').toggle_tool_output()` |
+| Toggle reasoning output (thinking steps) | `otr` | `:Opencode toggle_reasoning_output` | `require('opencode.api').toggle_reasoning_output()` |
+| Open a quick chat input with selection/current line context | `o/` | `:Opencode quick_chat` | `require('opencode.api').quick_chat()` |
### Run opts
@@ -605,6 +642,21 @@ The plugin defines several highlight groups that can be customized to match your
The `prompt_guard` configuration option allows you to control when prompts can be sent to Opencode. This is useful for preventing accidental or unauthorized AI interactions in certain contexts.
+### Configuration
+
+Set `prompt_guard` to a function that returns a boolean:
+
+```lua
+require('opencode').setup({
+ prompt_guard = function()
+ -- Your custom logic here
+ -- Return true to allow, false to deny
+ return true
+ end,
+})
+
+```
+
## 🪝 Custom user hooks
You can define custom functions to be called at specific events in Opencode:
@@ -637,20 +689,6 @@ require('opencode').setup({
})
```
-### Configuration
-
-Set `prompt_guard` to a function that returns a boolean:
-
-```lua
-require('opencode').setup({
- prompt_guard = function()
- -- Your custom logic here
- -- Return true to allow, false to deny
- return true
- end,
-})
-```
-
### Behavior
- **Before sending prompts**: The guard is checked before any prompt is sent to the AI. If denied, an ERROR notification is shown and the prompt is not sent.
@@ -658,6 +696,32 @@ require('opencode').setup({
- **No parameters**: The guard function receives no parameters. Access vim state directly (e.g., `vim.fn.getcwd()`, `vim.bo.filetype`).
- **Error handling**: If the guard function throws an error or returns a non-boolean value, the prompt is denied with an appropriate error message.
+## Quick chat
+
+Quick chat allows you to start a temporary opencode session with context from the current line or selection.
+This is optimized for narrow code edits or insertion. When the request is complex it will and require more context, it is recommended to use the full opencode UI.
+
+Due to the narrow context the resulting may be less accurate and edits may sometime fails. For best results, try to keep the request focused and simple.
+
+### Starting a quick chat
+
+Press `o/` in normal mode to open a quick chat input window.
+
+
+

+
+
+

+
+
+### Example chat prompts
+
+- Transform to a lua array
+- Add lua annotations
+- Write a conventional commit message for my changes #diff
+- Fix these warnings #warn
+- complete this function
+
## 🔧 Setting up Opencode
If you're new to opencode:
@@ -673,3 +737,8 @@ If you're new to opencode:
3. **Configuration:**
- Run `opencode auth login` to set up your LLM provider
- Configure your preferred LLM provider and model in the `~/.config/opencode/config.json` or `~/.config/opencode/opencode.json` file
+
+## 🙏 Acknowledgements
+
+This plugin is a fork of the original [goose.nvim](https://github.com/azorng/goose.nvim) plugin by [azorng](https://github.com/azorng/)
+For git history purposes the original code is copied instead of just forked.
diff --git a/lua/opencode/api.lua b/lua/opencode/api.lua
index 0deb2dab..f57957c6 100644
--- a/lua/opencode/api.lua
+++ b/lua/opencode/api.lua
@@ -3,6 +3,7 @@ local util = require('opencode.util')
local session = require('opencode.session')
local config_file = require('opencode.config_file')
local state = require('opencode.state')
+local quick_chat = require('opencode.quick_chat')
local input_window = require('opencode.ui.input_window')
local ui = require('opencode.ui.ui')
@@ -59,6 +60,8 @@ M.toggle = Promise.async(function(new_session)
end
end)
+---@param new_session boolean?
+---@return nil
function M.toggle_focus(new_session)
if not ui.is_opencode_focused() then
local focus = state.last_focused_opencode_window or 'input' ---@cast focus 'input' | 'output'
@@ -107,6 +110,39 @@ function M.select_history()
require('opencode.ui.history_picker').pick()
end
+function M.quick_chat(message, range)
+ if not range then
+ if vim.fn.mode():match('[vV\022]') then
+ local visual_range = util.get_visual_range()
+ if visual_range then
+ range = {
+ start = visual_range.start_line,
+ stop = visual_range.end_line,
+ }
+ end
+ end
+ end
+
+ if type(message) == 'table' then
+ message = table.concat(message, ' ')
+ end
+
+ if not message or #message == 0 then
+ local scope = range and ('[selection: ' .. range.start .. '-' .. range.stop .. ']')
+ or '[line: ' .. tostring(vim.api.nvim_win_get_cursor(0)[1]) .. ']'
+ vim.ui.input({ prompt = 'Quick Chat Message: ' .. scope, win = { relative = 'cursor' } }, function(input)
+ if input and input ~= '' then
+ local prompt, ctx = util.parse_quick_context_args(input)
+ quick_chat.quick_chat(prompt, { context_config = ctx }, range)
+ end
+ end)
+ return
+ end
+
+ local prompt, ctx = util.parse_quick_context_args(message)
+ quick_chat.quick_chat(prompt, { context_config = ctx }, range)
+end
+
function M.toggle_pane()
return core.open({ new_session = false, focus = 'output' }):and_then(function()
ui.toggle_pane()
@@ -970,6 +1006,14 @@ M.commands = {
fn = M.toggle_zoom,
},
+ quick_chat = {
+ desc = 'Quick chat with current buffer or visual selection',
+ fn = M.quick_chat,
+ range = true, -- Enable range support for visual selections
+ nargs = '+', -- Allow multiple arguments
+ complete = false, -- No completion for custom messages
+ },
+
swap = {
desc = 'Swap pane position left/right',
fn = M.swap_position,
@@ -1011,7 +1055,7 @@ M.commands = {
local title = table.concat(vim.list_slice(args, 2), ' ')
M.rename_session(state.active_session, title)
else
- local valid_subcmds = table.concat(M.commands.session.completions, ', ')
+ local valid_subcmds = table.concat(M.commands.session.completions or {}, ', ')
vim.notify('Invalid session subcommand. Use: ' .. valid_subcmds, vim.log.levels.ERROR)
end
end,
@@ -1041,7 +1085,7 @@ M.commands = {
elseif subcmd == 'close' then
M.diff_close()
else
- local valid_subcmds = table.concat(M.commands.diff.completions, ', ')
+ local valid_subcmds = table.concat(M.commands.diff.completions or {}, ', ')
vim.notify('Invalid diff subcommand. Use: ' .. valid_subcmds, vim.log.levels.ERROR)
end
end,
@@ -1120,7 +1164,7 @@ M.commands = {
elseif subcmd == 'select' then
M.select_agent()
else
- local valid_subcmds = table.concat(M.commands.agent.completions, ', ')
+ local valid_subcmds = table.concat(M.commands.agent.completions or {}, ', ')
vim.notify('Invalid agent subcommand. Use: ' .. valid_subcmds, vim.log.levels.ERROR)
end
end,
@@ -1208,7 +1252,7 @@ M.commands = {
elseif subcmd == 'deny' then
M.permission_deny()
else
- local valid_subcmds = table.concat(M.commands.permission.completions, ', ')
+ local valid_subcmds = table.concat(M.commands.permission.completions or {}, ', ')
vim.notify('Invalid permission subcommand. Use: ' .. valid_subcmds, vim.log.levels.ERROR)
end
end,
@@ -1309,6 +1353,14 @@ M.legacy_command_map = {
function M.route_command(opts)
local args = vim.split(opts.args or '', '%s+', { trimempty = true })
+ local range = nil
+
+ if opts.range and opts.range > 0 then
+ range = {
+ start = opts.line1,
+ stop = opts.line2,
+ }
+ end
if #args == 0 then
M.toggle()
@@ -1319,7 +1371,7 @@ function M.route_command(opts)
local subcmd_def = M.commands[subcommand]
if subcmd_def and subcmd_def.fn then
- subcmd_def.fn(vim.list_slice(args, 2))
+ subcmd_def.fn(vim.list_slice(args, 2), range)
else
vim.notify('Unknown subcommand: ' .. subcommand, vim.log.levels.ERROR)
end
@@ -1413,6 +1465,7 @@ function M.setup()
vim.api.nvim_create_user_command('Opencode', M.route_command, {
desc = 'Opencode.nvim main command with nested subcommands',
nargs = '*',
+ range = true, -- Enable range support
complete = M.complete_command,
})
diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua
index 21f5a1e4..7a897862 100644
--- a/lua/opencode/config.lua
+++ b/lua/opencode/config.lua
@@ -43,6 +43,7 @@ M.defaults = {
['oPd'] = { 'permission_deny', desc = 'Deny permission' },
['otr'] = { 'toggle_reasoning_output', desc = 'Toggle reasoning output' },
['ott'] = { 'toggle_tool_output', desc = 'Toggle tool output' },
+ ['o/'] = { 'quick_chat', desc = 'Quick chat with current context', mode = { 'n', 'x' } },
},
output_window = {
[''] = { 'close' },
@@ -94,6 +95,9 @@ M.defaults = {
delete_entry = { '', mode = { 'i', 'n' } },
clear_all = { '', mode = { 'i', 'n' } },
},
+ quick_chat = {
+ cancel = { '', mode = { 'i', 'n' } },
+ },
},
ui = {
position = 'right',
@@ -170,12 +174,14 @@ M.defaults = {
enabled = true,
cursor_data = {
enabled = false,
+ context_lines = 5, -- Number of lines before and after cursor to include in context
},
diagnostics = {
enabled = true,
info = false,
warning = true,
error = true,
+ only_closest = false, -- If true, only diagnostics for cursor/selection
},
current_file = {
enabled = true,
@@ -191,11 +197,21 @@ M.defaults = {
agents = {
enabled = true,
},
+ buffer = {
+ enabled = false, -- Disable entire buffer context by default, only used in quick chat
+ },
+ git_diff = {
+ enabled = false,
+ },
},
debug = {
enabled = false,
capture_streamed_events = false,
show_ids = true,
+ quick_chat = {
+ keep_session = false,
+ set_active_session = false,
+ },
},
prompt_guard = nil,
hooks = {
@@ -204,22 +220,15 @@ M.defaults = {
on_done_thinking = nil,
on_permission_requested = nil,
},
+ quick_chat = {
+ default_model = nil,
+ default_agent = nil,
+ instructions = nil, -- Use instructions prompt by default
+ },
}
M.values = vim.deepcopy(M.defaults)
----Get function names from keymap config, used when normalizing legacy config
----@param keymap_config table
-local function get_function_names(keymap_config)
- local names = {}
- for _, config in pairs(keymap_config) do
- if type(config) == 'table' and config[1] then
- table.insert(names, config[1])
- end
- end
- return names
-end
-
local function update_keymap_prefix(prefix, default_prefix)
if prefix == default_prefix or not prefix then
return
diff --git a/lua/opencode/context.lua b/lua/opencode/context.lua
index 60fdc32c..6695f548 100644
--- a/lua/opencode/context.lua
+++ b/lua/opencode/context.lua
@@ -1,145 +1,88 @@
--- Gathers editor context
-
local util = require('opencode.util')
local config = require('opencode.config')
local state = require('opencode.state')
+local Promise = require('opencode.promise')
-local M = {}
+local ChatContext = require('opencode.context.chat_context')
+local QuickChatContext = require('opencode.context.quick_chat_context')
+local BaseContext = require('opencode.context.base_context')
----@type OpencodeContext
-M.context = {
- -- current file
- current_file = nil,
- cursor_data = nil,
+local M = {}
- -- attachments
- mentioned_files = nil,
- selections = {},
- linter_errors = {},
- mentioned_subagents = {},
-}
+M.ChatContext = ChatContext
+M.QuickChatContext = QuickChatContext
-function M.unload_attachments()
- M.context.mentioned_files = nil
- M.context.selections = nil
- M.context.linter_errors = nil
+-- Provide access to the context state
+function M.get_context()
+ return ChatContext.context
end
-function M.get_current_buf()
- local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf()
- if util.is_buf_a_file(curr_buf) then
- return curr_buf, state.last_code_win_before_opencode or vim.api.nvim_get_current_win()
- end
+--- Formats context for main chat interface (new simplified API)
+---@param prompt string The user's instruction/prompt
+---@param context_config? OpencodeContextConfig Optional context config
+---@param opts? { range?: { start: integer, stop: integer } }
+---@return table result { parts: OpencodeMessagePart[] }
+M.format_chat_message = function(prompt, context_config, opts)
+ opts = opts or {}
+ opts.context_config = context_config
+ return ChatContext.format_message(prompt, opts)
end
-function M.load()
- local buf, win = M.get_current_buf()
-
- if buf then
- local current_file = M.get_current_file(buf)
- local cursor_data = M.get_current_cursor_data(buf, win)
-
- M.context.current_file = current_file
- M.context.cursor_data = cursor_data
- M.context.linter_errors = M.get_diagnostics(buf)
- end
-
- local current_selection = M.get_current_selection()
- if current_selection then
- local selection = M.new_selection(M.context.current_file, current_selection.text, current_selection.lines)
- M.add_selection(selection)
- end
- state.context_updated_at = vim.uv.now()
+--- Formats context for quick chat interface (new simplified API)
+---@param prompt string The user's instruction/prompt
+---@param context_config? OpencodeContextConfig Optional context config
+---@param opts? { range?: { start: integer, stop: integer } }
+---@return table result { text: string, parts: OpencodeMessagePart[] }
+M.format_quick_chat_message = function(prompt, context_config, opts)
+ opts = opts or {}
+ opts.context_config = context_config
+ return QuickChatContext.format_message(prompt, opts)
end
--- Checks if a context feature is enabled in config or state
----@param context_key string
----@return boolean
-function M.is_context_enabled(context_key)
- local is_enabled = vim.tbl_get(config --[[@as table]], 'context', context_key, 'enabled')
- local is_state_enabled = vim.tbl_get(state, 'current_context_config', context_key, 'enabled')
-
- if is_state_enabled ~= nil then
- return is_state_enabled
- else
- return is_enabled
- end
+function M.get_current_buf()
+ return BaseContext.get_current_buf()
end
----@return OpencodeDiagnostic[]|nil
-function M.get_diagnostics(buf)
- if not M.is_context_enabled('diagnostics') then
- return nil
- end
-
- local current_conf = vim.tbl_get(state, 'current_context_config', 'diagnostics') or {}
- if current_conf.enabled == false then
- return {}
- end
+function M.is_context_enabled(context_key, context_config)
+ return BaseContext.is_context_enabled(context_key, context_config)
+end
- local global_conf = vim.tbl_get(config --[[@as table]], 'context', 'diagnostics') or {}
- local diagnostic_conf = vim.tbl_deep_extend('force', global_conf, current_conf) or {}
+function M.get_diagnostics(buf, context_config, range)
+ return BaseContext.get_diagnostics(buf, context_config, range)
+end
- local severity_levels = {}
- if diagnostic_conf.error then
- table.insert(severity_levels, vim.diagnostic.severity.ERROR)
- end
- if diagnostic_conf.warning then
- table.insert(severity_levels, vim.diagnostic.severity.WARN)
- end
- if diagnostic_conf.info then
- table.insert(severity_levels, vim.diagnostic.severity.INFO)
- end
+function M.get_current_file(buf, context_config)
+ return BaseContext.get_current_file(buf, context_config)
+end
- local diagnostics = vim.diagnostic.get(buf, { severity = severity_levels })
- if #diagnostics == 0 then
- return {}
- end
+function M.get_current_cursor_data(buf, win, context_config)
+ return BaseContext.get_current_cursor_data(buf, win, context_config)
+end
- return diagnostics
+function M.get_current_selection(context_config)
+ return BaseContext.get_current_selection(context_config)
end
function M.new_selection(file, content, lines)
- return {
- file = file,
- content = util.indent_code_block(content),
- lines = lines,
- }
+ return BaseContext.new_selection(file, content, lines)
end
+-- Delegate global state management to ChatContext
function M.add_selection(selection)
- if not M.context.selections then
- M.context.selections = {}
- end
-
- table.insert(M.context.selections, selection)
-
+ ChatContext.add_selection(selection)
state.context_updated_at = vim.uv.now()
end
function M.remove_selection(selection)
- if not M.context.selections then
- return
- end
-
- for i, sel in ipairs(M.context.selections) do
- if sel.file.path == selection.file.path and sel.lines == selection.lines then
- table.remove(M.context.selections, i)
- break
- end
- end
+ ChatContext.remove_selection(selection)
state.context_updated_at = vim.uv.now()
end
function M.clear_selections()
- M.context.selections = nil
+ ChatContext.clear_selections()
end
function M.add_file(file)
- if not M.context.mentioned_files then
- M.context.mentioned_files = {}
- end
-
local is_file = vim.fn.filereadable(file) == 1
local is_dir = vim.fn.isdirectory(file) == 1
if not is_file and not is_dir then
@@ -153,299 +96,57 @@ function M.add_file(file)
end
file = vim.fn.fnamemodify(file, ':p')
-
- if not vim.tbl_contains(M.context.mentioned_files, file) then
- table.insert(M.context.mentioned_files, file)
- end
-
+ ChatContext.add_file(file)
state.context_updated_at = vim.uv.now()
end
function M.remove_file(file)
file = vim.fn.fnamemodify(file, ':p')
- if not M.context.mentioned_files then
- return
- end
-
- for i, f in ipairs(M.context.mentioned_files) do
- if f == file then
- table.remove(M.context.mentioned_files, i)
- break
- end
- end
+ ChatContext.remove_file(file)
state.context_updated_at = vim.uv.now()
end
function M.clear_files()
- M.context.mentioned_files = nil
+ ChatContext.clear_files()
end
function M.add_subagent(subagent)
- if not M.context.mentioned_subagents then
- M.context.mentioned_subagents = {}
- end
-
- if not vim.tbl_contains(M.context.mentioned_subagents, subagent) then
- table.insert(M.context.mentioned_subagents, subagent)
- end
+ ChatContext.add_subagent(subagent)
state.context_updated_at = vim.uv.now()
end
function M.remove_subagent(subagent)
- if not M.context.mentioned_subagents then
- return
- end
-
- for i, a in ipairs(M.context.mentioned_subagents) do
- if a == subagent then
- table.remove(M.context.mentioned_subagents, i)
- break
- end
- end
+ ChatContext.remove_subagent(subagent)
state.context_updated_at = vim.uv.now()
end
function M.clear_subagents()
- M.context.mentioned_subagents = nil
+ ChatContext.clear_subagents()
end
----@param opts? OpencodeContextConfig
----@return OpencodeContext
-function M.delta_context(opts)
- opts = opts or config.context
- if opts.enabled == false then
- return {
- current_file = nil,
- mentioned_files = nil,
- selections = nil,
- linter_errors = nil,
- cursor_data = nil,
- mentioned_subagents = nil,
- }
- end
-
- local context = vim.deepcopy(M.context)
- local last_context = state.last_sent_context
- if not last_context then
- return context
- end
-
- -- no need to send file context again
- if
- context.current_file
- and last_context.current_file
- and context.current_file.name == last_context.current_file.name
- then
- context.current_file = nil
- end
-
- -- no need to send subagents again
- if
- context.mentioned_subagents
- and last_context.mentioned_subagents
- and vim.deep_equal(context.mentioned_subagents, last_context.mentioned_subagents)
- then
- context.mentioned_subagents = nil
- end
-
- return context
-end
-
-function M.get_current_file(buf)
- if not M.is_context_enabled('current_file') then
- return nil
- end
- local file = vim.api.nvim_buf_get_name(buf)
- if not file or file == '' or vim.fn.filereadable(file) ~= 1 then
- return nil
- end
- return {
- path = file,
- name = vim.fn.fnamemodify(file, ':t'),
- extension = vim.fn.fnamemodify(file, ':e'),
- }
-end
-
-function M.get_current_cursor_data(buf, win)
- if not M.is_context_enabled('cursor_data') then
- return nil
- end
-
- local cursor_pos = vim.fn.getcurpos(win)
- local start_line = (cursor_pos[2] - 1) --[[@as integer]]
- local cursor_content = vim.trim(vim.api.nvim_buf_get_lines(buf, start_line, cursor_pos[2], false)[1] or '')
- return { line = cursor_pos[2], column = cursor_pos[3], line_content = cursor_content }
-end
-
-function M.get_current_selection()
- if not M.is_context_enabled('selection') then
- return nil
- end
-
- -- Return nil if not in a visual mode
- if not vim.fn.mode():match('[vV\022]') then
- return nil
- end
-
- -- Save current position and register state
- local current_pos = vim.fn.getpos('.')
- local old_reg = vim.fn.getreg('x')
- local old_regtype = vim.fn.getregtype('x')
-
- -- Capture selection text and position
- vim.cmd('normal! "xy')
- local text = vim.fn.getreg('x')
-
- -- Get line numbers
- vim.cmd('normal! `<')
- local start_line = vim.fn.line('.')
- vim.cmd('normal! `>')
- local end_line = vim.fn.line('.')
-
- -- Restore state
- vim.fn.setreg('x', old_reg, old_regtype)
- vim.cmd('normal! gv')
- vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', true)
- vim.fn.setpos('.', current_pos)
-
- if not text or text == '' then
- return nil
- end
-
- return {
- text = text and text:match('[^%s]') and text or nil,
- lines = start_line .. ', ' .. end_line,
- }
-end
-
-local function format_file_part(path, prompt)
- local rel_path = vim.fn.fnamemodify(path, ':~:.')
- local mention = '@' .. rel_path
- local pos = prompt and prompt:find(mention)
- pos = pos and pos - 1 or 0 -- convert to 0-based index
-
- local ext = vim.fn.fnamemodify(path, ':e'):lower()
- local mime_type = 'text/plain'
- if ext == 'png' then
- mime_type = 'image/png'
- elseif ext == 'jpg' or ext == 'jpeg' then
- mime_type = 'image/jpeg'
- elseif ext == 'gif' then
- mime_type = 'image/gif'
- elseif ext == 'webp' then
- mime_type = 'image/webp'
- end
-
- local file_part = { filename = rel_path, type = 'file', mime = mime_type, url = 'file://' .. path }
- if prompt then
- file_part.source = {
- path = path,
- type = 'file',
- text = { start = pos, value = mention, ['end'] = pos + #mention },
- }
- end
- return file_part
-end
-
----@param selection OpencodeContextSelection
-local function format_selection_part(selection)
- local lang = util.get_markdown_filetype(selection.file and selection.file.name or '') or ''
-
- return {
- type = 'text',
- text = vim.json.encode({
- context_type = 'selection',
- file = selection.file,
- content = string.format('`````%s\n%s\n`````', lang, selection.content),
- lines = selection.lines,
- }),
- synthetic = true,
- }
-end
-
----@param diagnostics OpencodeDiagnostic[]
-local function format_diagnostics_part(diagnostics)
- local diag_list = {}
- for _, diag in ipairs(diagnostics) do
- local short_msg = diag.message:gsub('%s+', ' '):gsub('^%s', ''):gsub('%s$', '')
- table.insert(
- diag_list,
- { msg = short_msg, severity = diag.severity, pos = 'l' .. diag.lnum + 1 .. ':c' .. diag.col + 1 }
- )
- end
- return {
- type = 'text',
- text = vim.json.encode({ context_type = 'diagnostics', content = diag_list }),
- synthetic = true,
- }
+function M.unload_attachments()
+ ChatContext.clear_files()
+ ChatContext.clear_selections()
end
-local function format_cursor_data_part(cursor_data)
- local buf = (M.get_current_buf() or 0) --[[@as integer]]
- local lang = util.get_markdown_filetype(vim.api.nvim_buf_get_name(buf)) or ''
- return {
- type = 'text',
- text = vim.json.encode({
- context_type = 'cursor-data',
- line = cursor_data.line,
- column = cursor_data.column,
- line_content = string.format('`````%s\n%s\n`````', lang, cursor_data.line_content),
- }),
- synthetic = true,
- }
+function M.load()
+ -- Delegate to ChatContext which manages the global state
+ ChatContext.load()
+ state.context_updated_at = vim.uv.now()
end
-local function format_subagents_part(agent, prompt)
- local mention = '@' .. agent
- local pos = prompt:find(mention)
- pos = pos and pos - 1 or 0 -- convert to 0-based index
-
- return {
- type = 'agent',
- name = agent,
- source = { value = mention, start = pos, ['end'] = pos + #mention },
- }
+-- Context creation with delta logic (delegates to ChatContext)
+function M.delta_context(opts)
+ return ChatContext.delta_context(opts)
end
---- Formats a prompt and context into message with parts for the opencode API
---@param prompt string
---@param opts? OpencodeContextConfig|nil
---@return OpencodeMessagePart[]
-function M.format_message(prompt, opts)
- opts = opts or config.context
- local context = M.delta_context(opts)
-
- local parts = { { type = 'text', text = prompt } }
-
- for _, path in ipairs(context.mentioned_files or {}) do
- -- don't resend current file if it's also mentioned
- if not context.current_file or path ~= context.current_file.path then
- table.insert(parts, format_file_part(path, prompt))
- end
- end
-
- for _, sel in ipairs(context.selections or {}) do
- table.insert(parts, format_selection_part(sel))
- end
-
- for _, agent in ipairs(context.mentioned_subagents or {}) do
- table.insert(parts, format_subagents_part(agent, prompt))
- end
-
- if context.current_file then
- table.insert(parts, format_file_part(context.current_file.path))
- end
-
- if context.linter_errors and #context.linter_errors > 0 then
- table.insert(parts, format_diagnostics_part(context.linter_errors))
- end
-
- if context.cursor_data then
- table.insert(parts, format_cursor_data_part(context.cursor_data))
- end
-
- return parts
-end
+M.format_message = Promise.async(function(prompt, opts)
+ local result = ChatContext.format_message(prompt, { context_config = opts }):await()
+ return result.parts
+end)
---@param text string
---@param context_type string|nil
@@ -526,8 +227,12 @@ function M.extract_legacy_tag(tag, text)
end
function M.setup()
- state.subscribe({ 'current_code_buf', 'current_context_config', 'is_opencode_focused' }, function(a)
+ local debounced_load = util.debounce(function()
M.load()
+ end, 200)
+
+ state.subscribe({ 'current_code_buf', 'current_context_config', 'is_opencode_focused' }, function()
+ debounced_load()
end)
local augroup = vim.api.nvim_create_augroup('OpenCodeContext', { clear = true })
@@ -538,7 +243,7 @@ function M.setup()
local buf = args.buf
local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf()
if buf == curr_buf and util.is_buf_a_file(buf) then
- M.load()
+ debounced_load()
end
end,
})
@@ -550,7 +255,7 @@ function M.setup()
local buf = args.buf
local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf()
if buf == curr_buf and util.is_buf_a_file(buf) and M.is_context_enabled('diagnostics') then
- M.load()
+ debounced_load()
end
end,
})
diff --git a/lua/opencode/context/base_context.lua b/lua/opencode/context/base_context.lua
new file mode 100644
index 00000000..18cff5f3
--- /dev/null
+++ b/lua/opencode/context/base_context.lua
@@ -0,0 +1,242 @@
+local util = require('opencode.util')
+local config = require('opencode.config')
+local state = require('opencode.state')
+local Promise = require('opencode.promise')
+
+local M = {}
+
+---@return integer|nil, integer|nil
+function M.get_current_buf()
+ local curr_buf = state.current_code_buf or vim.api.nvim_get_current_buf()
+ if util.is_buf_a_file(curr_buf) then
+ local win = vim.fn.win_findbuf(curr_buf --[[@as integer]])[1]
+ return curr_buf, state.last_code_win_before_opencode or win or vim.api.nvim_get_current_win()
+ end
+end
+
+-- Checks if a context feature is enabled in config or state
+---@param context_key string
+---@param context_config? OpencodeContextConfig
+---@return boolean
+function M.is_context_enabled(context_key, context_config)
+ if context_config then
+ local override_enabled = vim.tbl_get(context_config, context_key, 'enabled')
+ if override_enabled ~= nil then
+ return override_enabled
+ end
+ end
+
+ local is_enabled = vim.tbl_get(config --[[@as table]], 'context', context_key, 'enabled')
+ local is_state_enabled = vim.tbl_get(state, 'current_context_config', context_key, 'enabled')
+
+ if is_state_enabled ~= nil then
+ return is_state_enabled
+ else
+ return is_enabled
+ end
+end
+
+---@param buf integer
+---@param context_config? OpencodeContextConfig
+---@param range? { start_line: integer, end_line: integer } | { start_line: integer, end_line: integer }[]
+---@return OpencodeDiagnostic[]|nil
+function M.get_diagnostics(buf, context_config, range)
+ if not M.is_context_enabled('diagnostics', context_config) then
+ return nil
+ end
+
+ local current_conf = vim.tbl_get(state, 'current_context_config', 'diagnostics') or {}
+ if current_conf.enabled == false then
+ return {}
+ end
+
+ local global_conf = vim.tbl_get(config --[[@as table]], 'context', 'diagnostics') or {}
+ local override_conf = context_config and vim.tbl_get(context_config, 'diagnostics') or {}
+ local diagnostic_conf = vim.tbl_deep_extend('force', global_conf, current_conf, override_conf) or {}
+
+ local severity_levels = {}
+ if diagnostic_conf.error then
+ table.insert(severity_levels, vim.diagnostic.severity.ERROR)
+ end
+ if diagnostic_conf.warning then
+ table.insert(severity_levels, vim.diagnostic.severity.WARN)
+ end
+ if diagnostic_conf.info then
+ table.insert(severity_levels, vim.diagnostic.severity.INFO)
+ end
+
+ local diagnostics = {}
+
+ local ranges = nil
+ if range then
+ if range[1] and type(range[1]) == 'table' then
+ ranges = range
+ else
+ ranges = { range }
+ end
+ end
+
+ if diagnostic_conf.only_closest then
+ if ranges then
+ for _, r in ipairs(ranges) do
+ for line_num = r.start_line, r.end_line do
+ local line_diagnostics = vim.diagnostic.get(buf, {
+ lnum = line_num,
+ severity = severity_levels,
+ })
+ for _, diag in ipairs(line_diagnostics) do
+ table.insert(diagnostics, diag)
+ end
+ end
+ end
+ else
+ -- Get diagnostics for current cursor line only
+ local win = vim.fn.win_findbuf(buf)[1]
+ local cursor_pos = vim.fn.getcurpos(win)
+ local line_diagnostics = vim.diagnostic.get(buf, {
+ lnum = cursor_pos[2] - 1,
+ severity = severity_levels,
+ })
+ diagnostics = line_diagnostics
+ end
+ else
+ diagnostics = vim.diagnostic.get(buf, { severity = severity_levels })
+ end
+
+ if #diagnostics == 0 then
+ return {}
+ end
+
+ local opencode_diagnostics = {}
+ for _, diag in ipairs(diagnostics) do
+ table.insert(opencode_diagnostics, {
+ message = diag.message,
+ severity = diag.severity,
+ lnum = diag.lnum,
+ col = diag.col,
+ end_lnum = diag.end_lnum,
+ end_col = diag.end_col,
+ source = diag.source,
+ code = diag.code,
+ user_data = diag.user_data,
+ })
+ end
+
+ return opencode_diagnostics, ranges
+end
+
+---@param buf integer
+---@param context_config? OpencodeContextConfig
+---@return table|nil
+function M.get_current_file(buf, context_config)
+ if not M.is_context_enabled('current_file', context_config) then
+ return nil
+ end
+ local file = vim.api.nvim_buf_get_name(buf)
+ if not file or file == '' or vim.fn.filereadable(file) ~= 1 then
+ return nil
+ end
+ return {
+ path = file,
+ name = vim.fn.fnamemodify(file, ':t'),
+ extension = vim.fn.fnamemodify(file, ':e'),
+ }
+end
+
+---@param buf integer
+---@param win integer
+---@param context_config? OpencodeContextConfig
+---@return table|nil
+function M.get_current_cursor_data(buf, win, context_config)
+ if not M.is_context_enabled('cursor_data', context_config) then
+ return nil
+ end
+
+ local num_lines = config.context.cursor_data.context_lines --[[@as integer]]
+ or 0
+ local cursor_pos = vim.fn.getcurpos(win)
+ local start_line = (cursor_pos[2] - 1) --[[@as integer]]
+ local cursor_content = vim.api.nvim_buf_get_lines(buf, start_line, cursor_pos[2], false)[1] or ''
+ local lines_before = vim.api.nvim_buf_get_lines(buf, math.max(0, start_line - num_lines), start_line, false)
+ local lines_after = vim.api.nvim_buf_get_lines(buf, cursor_pos[2], cursor_pos[2] + num_lines, false)
+ return {
+ line = cursor_pos[2],
+ column = cursor_pos[3],
+ line_content = cursor_content,
+ lines_before = lines_before,
+ lines_after = lines_after,
+ }
+end
+
+---@param context_config? OpencodeContextConfig
+---@return table|nil
+function M.get_current_selection(context_config)
+ if not M.is_context_enabled('selection', context_config) then
+ return nil
+ end
+
+ -- Return nil if not in a visual mode
+ if not vim.fn.mode():match('[vV\022]') then
+ return nil
+ end
+
+ -- Save current position and register state
+ local current_pos = vim.fn.getpos('.')
+ local old_reg = vim.fn.getreg('x')
+ local old_regtype = vim.fn.getregtype('x')
+
+ -- Capture selection text and position
+ vim.cmd('normal! "xy')
+ local text = vim.fn.getreg('x')
+
+ -- Get line numbers
+ vim.cmd('normal! `<')
+ local start_line = vim.fn.line('.')
+ vim.cmd('normal! `>')
+ local end_line = vim.fn.line('.')
+
+ -- Restore state
+ vim.fn.setreg('x', old_reg, old_regtype)
+ vim.cmd('normal! gv')
+ vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', true)
+ vim.fn.setpos('.', current_pos)
+
+ if not text or text == '' then
+ return nil
+ end
+
+ return {
+ text = text and text:match('[^%s]') and text or nil,
+ lines = start_line .. ', ' .. end_line,
+ }
+end
+
+---@param context_config? OpencodeContextConfig
+---@return string|nil
+M.get_git_diff = Promise.async(function(context_config)
+ if not M.is_context_enabled('git_diff', context_config) then
+ return nil
+ end
+
+ return Promise.system({ 'git', 'diff', '--cached' }):and_then(function(output)
+ if output == '' then
+ return nil
+ end
+ return output.stdout
+ end)
+end)
+
+---@param file table
+---@param content string
+---@param lines string
+---@param raw_indent? boolean
+---@return table
+function M.new_selection(file, content, lines, raw_indent)
+ return {
+ file = file,
+ content = raw_indent and content or util.indent_code_block(content),
+ lines = lines,
+ }
+end
+
+return M
diff --git a/lua/opencode/context/chat_context.lua b/lua/opencode/context/chat_context.lua
new file mode 100644
index 00000000..445feeab
--- /dev/null
+++ b/lua/opencode/context/chat_context.lua
@@ -0,0 +1,568 @@
+local base_context = require('opencode.context.base_context')
+local util = require('opencode.util')
+local state = require('opencode.state')
+local Promise = require('opencode.promise')
+
+local M = {}
+
+M.context = {
+ mentioned_files = {},
+ selections = {},
+ mentioned_subagents = {},
+ current_file = nil,
+ cursor_data = nil,
+ linter_errors = nil,
+}
+
+---@param path string
+---@param prompt? string
+---@return OpencodeMessagePart
+local function format_file_part(path, prompt)
+ local rel_path = vim.fn.fnamemodify(path, ':~:.')
+ local mention = '@' .. rel_path
+ local pos = prompt and prompt:find(mention)
+ pos = pos and pos - 1 or 0 -- convert to 0-based index
+
+ local ext = vim.fn.fnamemodify(path, ':e'):lower()
+ local mime_type = 'text/plain'
+ if ext == 'png' then
+ mime_type = 'image/png'
+ elseif ext == 'jpg' or ext == 'jpeg' then
+ mime_type = 'image/jpeg'
+ elseif ext == 'gif' then
+ mime_type = 'image/gif'
+ elseif ext == 'webp' then
+ mime_type = 'image/webp'
+ end
+
+ local file_part = { filename = rel_path, type = 'file', mime = mime_type, url = 'file://' .. path }
+ if prompt then
+ file_part.source = {
+ path = path,
+ type = 'file',
+ text = { start = pos, value = mention, ['end'] = pos + #mention },
+ }
+ end
+ return file_part
+end
+
+---@param selection OpencodeContextSelection
+---@return OpencodeMessagePart
+local function format_selection_part(selection)
+ local lang = util.get_markdown_filetype(selection.file and selection.file.name or '') or ''
+
+ return {
+ type = 'text',
+ metadata = {
+ context_type = 'selection',
+ },
+ text = vim.json.encode({
+ context_type = 'selection',
+ file = selection.file,
+ content = string.format('`````%s\n%s\n`````', lang, selection.content),
+ lines = selection.lines,
+ }),
+ synthetic = true,
+ }
+end
+
+---@param diagnostics OpencodeDiagnostic[]
+---@param range? { start_line: integer, end_line: integer }|nil
+---@return OpencodeMessagePart
+local function format_diagnostics_part(diagnostics, range)
+ local diag_list = {}
+ for _, diag in ipairs(diagnostics) do
+ if not range or (diag.lnum >= range.start_line and diag.lnum <= range.end_line) then
+ local short_msg = diag.message:gsub('%s+', ' '):gsub('^%s', ''):gsub('%s$', '')
+ table.insert(
+ diag_list,
+ { msg = short_msg, severity = diag.severity, pos = 'l' .. diag.lnum + 1 .. ':c' .. diag.col + 1 }
+ )
+ end
+ end
+ return {
+ type = 'text',
+ metadata = {
+ context_type = 'diagnostics',
+ },
+ text = vim.json.encode({ context_type = 'diagnostics', content = diag_list }),
+ synthetic = true,
+ }
+end
+
+---@param cursor_data table
+---@param get_current_buf fun(): integer|nil Function to get current buffer
+---@return OpencodeMessagePart
+local function format_cursor_data_part(cursor_data, get_current_buf)
+ local buf = (get_current_buf() or 0) --[[@as integer]]
+ local lang = util.get_markdown_filetype(vim.api.nvim_buf_get_name(buf)) or ''
+ return {
+ type = 'text',
+ metadata = {
+ context_type = 'cursor-data',
+ lang = lang,
+ },
+ text = vim.json.encode({
+ context_type = 'cursor-data',
+ line = cursor_data.line,
+ column = cursor_data.column,
+ line_content = string.format('`````%s\n%s\n`````', lang, cursor_data.line_content),
+ lines_before = cursor_data.lines_before,
+ lines_after = cursor_data.lines_after,
+ }),
+ synthetic = true,
+ }
+end
+
+---@param agent string
+---@param prompt string
+---@return OpencodeMessagePart
+local function format_subagents_part(agent, prompt)
+ local mention = '@' .. agent
+ local pos = prompt:find(mention)
+ pos = pos and pos - 1 or 0 -- convert to 0-based index
+
+ return {
+ type = 'agent',
+ name = agent,
+ source = { value = mention, start = pos, ['end'] = pos + #mention },
+ }
+end
+
+---@param buf integer
+---@return OpencodeMessagePart
+local function format_buffer_part(buf)
+ local file = vim.api.nvim_buf_get_name(buf)
+ local rel_path = vim.fn.fnamemodify(file, ':~:.')
+ return {
+ type = 'text',
+ text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), '\n'),
+ metadata = {
+ context_type = 'file-content',
+ filename = rel_path,
+ mime = 'text/plain',
+ },
+ synthetic = true,
+ }
+end
+
+---@param diff_text string
+---@return OpencodeMessagePart
+local function format_git_diff_part(diff_text)
+ return {
+ type = 'text',
+ metadata = {
+ context_type = 'git-diff',
+ },
+ text = diff_text,
+ synthetic = true,
+ }
+end
+
+-- Global context management functions
+
+function M.add_selection(selection)
+ -- Ensure selections is always a table
+ if not M.context.selections then
+ M.context.selections = {}
+ end
+
+ table.insert(M.context.selections, selection)
+ state.context_updated_at = vim.uv.now()
+end
+
+function M.remove_selection(selection)
+ if not M.context.selections then
+ M.context.selections = {}
+ return
+ end
+
+ for i, sel in ipairs(M.context.selections) do
+ if sel.file.path == selection.file.path and sel.lines == selection.lines then
+ table.remove(M.context.selections, i)
+ break
+ end
+ end
+ state.context_updated_at = vim.uv.now()
+end
+
+function M.clear_selections()
+ M.context.selections = {}
+end
+
+function M.add_file(file)
+ local is_file = vim.fn.filereadable(file) == 1
+ local is_dir = vim.fn.isdirectory(file) == 1
+ if not is_file and not is_dir then
+ vim.notify('File not added to context. Could not read.')
+ return
+ end
+
+ if not util.is_path_in_cwd(file) and not util.is_temp_path(file, 'pasted_image') then
+ vim.notify('File not added to context. Must be inside current working directory.')
+ return
+ end
+
+ file = vim.fn.fnamemodify(file, ':p')
+
+ if not M.context.mentioned_files then
+ M.context.mentioned_files = {}
+ end
+
+ if not vim.tbl_contains(M.context.mentioned_files, file) then
+ table.insert(M.context.mentioned_files, file)
+ end
+ state.context_updated_at = vim.uv.now()
+end
+
+function M.remove_file(file)
+ if not M.context.mentioned_files then
+ M.context.mentioned_files = {}
+ return
+ end
+
+ file = vim.fn.fnamemodify(file, ':p')
+ for i, f in ipairs(M.context.mentioned_files) do
+ if f == file then
+ table.remove(M.context.mentioned_files, i)
+ break
+ end
+ end
+ state.context_updated_at = vim.uv.now()
+end
+
+function M.clear_files()
+ M.context.mentioned_files = {}
+end
+
+function M.add_subagent(subagent)
+ -- Ensure mentioned_subagents is always a table
+ if not M.context.mentioned_subagents then
+ M.context.mentioned_subagents = {}
+ end
+
+ if not vim.tbl_contains(M.context.mentioned_subagents, subagent) then
+ table.insert(M.context.mentioned_subagents, subagent)
+ end
+ state.context_updated_at = vim.uv.now()
+end
+
+function M.remove_subagent(subagent)
+ if not M.context.mentioned_subagents then
+ M.context.mentioned_subagents = {}
+ return
+ end
+
+ for i, a in ipairs(M.context.mentioned_subagents) do
+ if a == subagent then
+ table.remove(M.context.mentioned_subagents, i)
+ break
+ end
+ end
+ state.context_updated_at = vim.uv.now()
+end
+
+function M.clear_subagents()
+ M.context.mentioned_subagents = {}
+end
+
+function M.unload_attachments()
+ M.context.mentioned_files = {}
+ M.context.selections = {}
+end
+
+function M.get_mentioned_files()
+ return M.context.mentioned_files or {}
+end
+
+function M.get_selections()
+ return M.context.selections or {}
+end
+
+function M.get_mentioned_subagents()
+ return M.context.mentioned_subagents or {}
+end
+
+-- Chat-context-aware get_diagnostics that considers stored selections
+---@param buf integer
+---@param context_config? OpencodeContextConfig
+---@param range? { start_line: integer, end_line: integer }
+---@return OpencodeDiagnostic[]|nil
+function M.get_diagnostics(buf, context_config, range)
+ -- Use explicit range if provided
+ if range then
+ return base_context.get_diagnostics(buf, context_config, range)
+ end
+
+ if M.context.selections and #M.context.selections > 0 then
+ local selection_ranges = {}
+
+ for _, sel in ipairs(M.context.selections) do
+ if sel.lines then
+ -- Handle both formats: "1, 5" and "1-5"
+ local start_line, end_line = sel.lines:match('(%d+)[,%-]%s*(%d+)')
+ if not start_line then
+ -- Single line case like "5, 5" or just "5"
+ start_line = sel.lines:match('(%d+)')
+ end_line = start_line
+ end
+
+ if start_line then
+ local start_num = tonumber(start_line)
+ local end_num = tonumber(end_line)
+
+ if start_num and end_num then
+ -- Convert to 0-based
+ local selection_range = {
+ start_line = start_num - 1,
+ end_line = end_num - 1,
+ }
+ table.insert(selection_ranges, selection_range)
+ end
+ end
+ end
+ end
+
+ if #selection_ranges > 0 then
+ return base_context.get_diagnostics(buf, context_config, selection_ranges)
+ end
+ end
+
+ return base_context.get_diagnostics(buf, context_config, nil)
+end
+
+---@param current_file table|nil
+---@return boolean, boolean -- should_update, is_different_file
+function M.should_update_current_file(current_file)
+ if not M.context.current_file then
+ return current_file ~= nil, false
+ end
+
+ if not current_file then
+ return false, false
+ end
+
+ -- Different file name means update needed
+ if M.context.current_file.name ~= current_file.name then
+ return true, true
+ end
+
+ -- Same file, check modification time
+ local file_path = current_file.path
+ if not file_path or vim.fn.filereadable(file_path) ~= 1 then
+ return false, false
+ end
+
+ local stat = vim.uv.fs_stat(file_path)
+ if not (stat and stat.mtime and stat.mtime.sec) then
+ return false, false
+ end
+
+ local file_mtime_sec = stat.mtime.sec --[[@as number]]
+ local last_sent_mtime = M.context.current_file.sent_at_mtime or 0
+ return file_mtime_sec > last_sent_mtime, false
+end
+
+-- Load function that populates the global context state
+-- This is the core loading logic that was originally in the main context module
+function M.load()
+ if not state.active_session then
+ return
+ end
+
+ local buf, win = base_context.get_current_buf()
+
+ if not buf or not win then
+ return
+ end
+
+ local current_file = base_context.get_current_file(buf)
+ local cursor_data = base_context.get_current_cursor_data(buf, win)
+
+ local should_update_file, is_different_file = M.should_update_current_file(current_file)
+
+ if should_update_file then
+ if is_different_file then
+ M.context.selections = {}
+ end
+
+ M.context.current_file = current_file
+ if M.context.current_file then
+ M.context.current_file.sent_at = nil
+ M.context.current_file.sent_at_mtime = nil
+ end
+ end
+
+ M.context.cursor_data = cursor_data
+ M.context.linter_errors = M.get_diagnostics(buf, nil, nil)
+
+ -- Handle current selection
+ local current_selection = base_context.get_current_selection()
+ if current_selection and M.context.current_file then
+ local selection =
+ base_context.new_selection(M.context.current_file, current_selection.text, current_selection.lines)
+ M.add_selection(selection)
+ end
+end
+
+---@param current_file table
+local function set_file_sent_timestamps(current_file)
+ if not current_file then
+ return
+ end
+ current_file.sent_at = vim.uv.now()
+ local stat = vim.uv.fs_stat(current_file.path)
+ if stat and stat.mtime and stat.mtime.sec then
+ current_file.sent_at_mtime = stat.mtime.sec
+ end
+end
+
+-- This function creates a context snapshot with delta logic against the last sent context
+function M.delta_context(opts)
+ local config = require('opencode.config')
+
+ opts = opts or state.current_context_config or config.context
+ if opts.enabled == false then
+ return {
+ current_file = nil,
+ mentioned_files = nil,
+ selections = nil,
+ linter_errors = nil,
+ cursor_data = nil,
+ mentioned_subagents = nil,
+ }
+ end
+
+ local buf, win = base_context.get_current_buf()
+ if not buf then
+ return {}
+ end
+
+ local ctx = vim.deepcopy(M.context)
+
+ if ctx.current_file and M.context.current_file then
+ set_file_sent_timestamps(M.context.current_file)
+ set_file_sent_timestamps(ctx.current_file)
+ end
+
+ -- no need to send subagents again
+ local last_context = state.last_sent_context
+ if last_context then
+ if
+ ctx.mentioned_subagents
+ and last_context.mentioned_subagents
+ and vim.deep_equal(ctx.mentioned_subagents, last_context.mentioned_subagents)
+ then
+ ctx.mentioned_subagents = nil
+ M.context.mentioned_subagents = nil
+ end
+ end
+
+ state.context_updated_at = vim.uv.now()
+ return ctx
+end
+
+--- Formats context as structured message parts for the main chat interface
+--- This is the main function that includes global state (mentioned files, selections, etc.)
+---@param prompt string The user's instruction/prompt
+---@param opts? { range?: { start: integer, stop: integer }, context_config?: OpencodeContextConfig }
+---@return table result { parts: OpencodeMessagePart[] }
+M.format_message = Promise.async(function(prompt, opts)
+ opts = opts or {}
+ local context_config = opts.context_config
+ local buf, win = base_context.get_current_buf()
+ local range = opts.range
+ local parts = {}
+
+ for _, file_path in ipairs(M.context.mentioned_files or {}) do
+ table.insert(parts, format_file_part(file_path, prompt))
+ end
+
+ for _, agent in ipairs(M.context.mentioned_subagents or {}) do
+ table.insert(parts, format_subagents_part(agent, prompt))
+ end
+
+ if not buf then
+ table.insert(parts, { type = 'text', text = prompt })
+ return { parts = parts }
+ end
+
+ if M.context.current_file and not M.context.current_file.sent_at then
+ table.insert(parts, format_file_part(M.context.current_file.path))
+ set_file_sent_timestamps(M.context.current_file)
+ end
+
+ if base_context.is_context_enabled('selection', context_config) then
+ local selections = {}
+
+ if range and range.start and range.stop then
+ local file = base_context.get_current_file(buf, context_config)
+ if file then
+ local selection = base_context.new_selection(
+ file,
+ table.concat(
+ vim.api.nvim_buf_get_lines(buf, math.floor(range.start) - 1, math.floor(range.stop), false),
+ '\n'
+ ),
+ string.format('%d-%d', math.floor(range.start), math.floor(range.stop))
+ )
+ table.insert(selections, selection)
+ end
+ end
+
+ local current_selection = base_context.get_current_selection(context_config)
+ if current_selection then
+ local file = base_context.get_current_file(buf, context_config)
+ if file then
+ local selection = base_context.new_selection(file, current_selection.text, current_selection.lines)
+ table.insert(selections, selection)
+ end
+ end
+
+ for _, sel in ipairs(M.context.selections or {}) do
+ table.insert(selections, sel)
+ end
+
+ for _, sel in ipairs(selections) do
+ table.insert(parts, format_selection_part(sel))
+ end
+ end
+
+ if base_context.is_context_enabled('buffer', context_config) then
+ table.insert(parts, format_buffer_part(buf))
+ end
+
+ local diag_range = nil
+ if range then
+ diag_range = { start_line = math.floor(range.start) - 1, end_line = math.floor(range.stop) - 1 }
+ end
+ local diagnostics = M.get_diagnostics(buf, context_config, diag_range)
+ if diagnostics and #diagnostics > 0 then
+ table.insert(parts, format_diagnostics_part(diagnostics, diag_range))
+ end
+
+ if base_context.is_context_enabled('cursor_data', context_config) then
+ local cursor_data = base_context.get_current_cursor_data(buf, win, context_config)
+ if cursor_data then
+ table.insert(
+ parts,
+ format_cursor_data_part(cursor_data, function()
+ return buf
+ end)
+ )
+ end
+ end
+
+ if base_context.is_context_enabled('git_diff', context_config) then
+ local diff_text = base_context.get_git_diff(context_config):await()
+ if diff_text and diff_text ~= '' then
+ table.insert(parts, format_git_diff_part(diff_text))
+ end
+ end
+
+ table.insert(parts, { type = 'text', text = prompt })
+
+ return { parts = parts }
+end)
+
+return M
diff --git a/lua/opencode/context/quick_chat_context.lua b/lua/opencode/context/quick_chat_context.lua
new file mode 100644
index 00000000..6735d5c8
--- /dev/null
+++ b/lua/opencode/context/quick_chat_context.lua
@@ -0,0 +1,208 @@
+local base_context = require('opencode.context.base_context')
+local util = require('opencode.util')
+local Promise = require('opencode.promise')
+
+local M = {}
+
+local severity_names = {
+ [1] = 'ERROR',
+ [2] = 'WARNING',
+ [3] = 'INFO',
+ [4] = 'HINT',
+}
+
+---@param selection table
+---@return string
+local function format_selection(selection)
+ local lang = util.get_markdown_filetype(selection.file and selection.file.name or '') or ''
+ local file_info = selection.file and selection.file.name or 'unknown'
+ local lines_info = selection.lines and (' (lines ' .. selection.lines .. ')') or ''
+
+ local parts = {
+ '[SELECTED CODE] from ' .. file_info .. lines_info .. ':',
+ '```' .. lang,
+ selection.content,
+ '```',
+ }
+ return table.concat(parts, '\n')
+end
+
+---@param diagnostics OpencodeDiagnostic[]
+---@param range? { start_line: integer, end_line: integer }|nil
+---@return string|nil
+local function format_diagnostics(diagnostics, range)
+ if not diagnostics or #diagnostics == 0 then
+ return nil
+ end
+
+ local filtered = {}
+ for _, diag in ipairs(diagnostics) do
+ local in_range = not range or (diag.lnum >= range.start_line and diag.lnum <= range.end_line)
+ if in_range then
+ local severity = severity_names[diag.severity] or 'UNKNOWN'
+ local line_num = diag.lnum + 1
+ local col_num = diag.col + 1
+ local msg = diag.message:gsub('%s+', ' '):gsub('^%s', ''):gsub('%s$', '')
+ table.insert(filtered, string.format(' Line %d, Col %d [%s]: %s', line_num, col_num, severity, msg))
+ end
+ end
+
+ if #filtered == 0 then
+ return nil
+ end
+
+ return '[DIAGNOSTICS]:\n' .. table.concat(filtered, '\n')
+end
+
+---@param cursor_data table
+---@param lang string|nil
+---@return string
+local function format_cursor_data(cursor_data, lang)
+ local file = vim.api.nvim_buf_get_name(vim.api.nvim_get_current_buf())
+
+ lang = lang or ''
+ local parts = {
+ string.format('[CURSOR POSITION]: File %s ,Line %d, Column %d', file, cursor_data.line, cursor_data.column),
+ }
+
+ if cursor_data.lines_before and #cursor_data.lines_before > 0 then
+ table.insert(parts, '[BEFORE CURSOR]:')
+ table.insert(parts, '```' .. lang)
+ table.insert(parts, table.concat(cursor_data.lines_before, '\n'))
+ table.insert(parts, '```')
+ end
+
+ table.insert(parts, '[CURRENT LINE]:')
+ table.insert(parts, '```' .. lang)
+ table.insert(parts, cursor_data.line_content)
+ table.insert(parts, '```')
+
+ if cursor_data.lines_after and #cursor_data.lines_after > 0 then
+ table.insert(parts, '[AFTER CURSOR]:')
+ table.insert(parts, '```' .. lang)
+ table.insert(parts, table.concat(cursor_data.lines_after, '\n'))
+ table.insert(parts, '```')
+ end
+
+ return table.concat(parts, '\n')
+end
+
+---@param diff_text string
+---@return string
+local function format_git_diff(diff_text)
+ return '[GIT DIFF] (staged changes):\n```diff\n' .. diff_text .. '\n```'
+end
+
+---@param buf integer
+---@param lang string|nil
+---@return string
+local function format_buffer(buf, lang)
+ lang = lang or ''
+ local file = vim.api.nvim_buf_get_name(buf)
+ local rel_path = vim.fn.fnamemodify(file, ':~:.')
+ local content = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), '\n')
+
+ return string.format('[FILE]: %s\n\n```%s\n%s\n```', rel_path, lang, content)
+end
+
+---@return integer|nil, integer|nil
+function M.get_current_buf()
+ local buf = vim.api.nvim_get_current_buf()
+ local win = vim.api.nvim_get_current_win()
+ return buf, win
+end
+
+--- Formats context as plain text for LLM consumption (used by quick chat)
+--- Unlike ChatContext, this outputs human-readable text instead of structured JSON
+---@param prompt string The user's instruction/prompt
+---@param opts? { range?: { start: integer, stop: integer }, context_config?: OpencodeContextConfig }
+---@return table result { text: string, parts: OpencodeMessagePart[] }
+M.format_message = Promise.async(function(prompt, opts)
+ opts = opts or {}
+ local context_config = opts.context_config
+ local buf, win = M.get_current_buf()
+
+ if not buf or not win then
+ return {
+ text = '[USER PROMPT]: ' .. prompt,
+ parts = { { type = 'text', text = '[USER PROMPT]: ' .. prompt } },
+ }
+ end
+
+ local range = opts.range
+ local file_name = vim.api.nvim_buf_get_name(buf)
+ local lang = util.get_markdown_filetype(file_name) or vim.fn.fnamemodify(file_name, ':e') or ''
+
+ local text_parts = {}
+
+ if base_context.is_context_enabled('selection', context_config) then
+ local selections = {}
+
+ if range and range.start and range.stop then
+ local file = vim.fn.fnamemodify(vim.api.nvim_buf_get_name(buf), ':~:.')
+ if file then
+ local selection = base_context.new_selection(
+ {
+ name = file,
+ path = vim.api.nvim_buf_get_name(buf),
+ extension = vim.fn.fnamemodify(file, ':e'),
+ },
+ table.concat(vim.api.nvim_buf_get_lines(buf, range.start - 1, range.stop, false), '\n'),
+ string.format('%d-%d', range.start, range.stop),
+ true
+ )
+ table.insert(selections, selection)
+ end
+ end
+
+ for _, sel in ipairs(selections) do
+ table.insert(text_parts, '')
+ table.insert(text_parts, format_selection(sel))
+ end
+ end
+
+ if base_context.is_context_enabled('buffer', context_config) then
+ table.insert(text_parts, format_buffer(buf, lang))
+ end
+
+ local diag_range = nil
+ if range then
+ diag_range = { start_line = range.start - 1, end_line = range.stop - 1 }
+ end
+ local diagnostics = base_context.get_diagnostics(buf, context_config, diag_range)
+ if diagnostics and #diagnostics > 0 then
+ local formatted_diag = format_diagnostics(diagnostics, nil) -- No need to filter again
+ if formatted_diag then
+ table.insert(text_parts, '')
+ table.insert(text_parts, formatted_diag)
+ end
+ end
+
+ if base_context.is_context_enabled('cursor_data', context_config) then
+ local cursor_data = base_context.get_current_cursor_data(buf, win, context_config)
+ if cursor_data then
+ table.insert(text_parts, '')
+ table.insert(text_parts, format_cursor_data(cursor_data, lang))
+ end
+ end
+
+ if base_context.is_context_enabled('git_diff', context_config) then
+ local diff_text = base_context.get_git_diff(context_config):await()
+ if diff_text and diff_text ~= '' then
+ table.insert(text_parts, '')
+ table.insert(text_parts, format_git_diff(diff_text))
+ end
+ end
+
+ table.insert(text_parts, '')
+ table.insert(text_parts, '[USER PROMPT]: ' .. prompt)
+
+ local full_text = table.concat(text_parts, '\n')
+
+ return {
+ text = full_text,
+ parts = { { type = 'text', text = full_text } },
+ }
+end)
+
+return M
diff --git a/lua/opencode/core.lua b/lua/opencode/core.lua
index b48d643e..8e73e3b8 100644
--- a/lua/opencode/core.lua
+++ b/lua/opencode/core.lua
@@ -69,7 +69,7 @@ M.open = Promise.async(function(opts)
local are_windows_closed = state.windows == nil
if are_windows_closed then
-- Check if whether prompting will be allowed
- local mentioned_files = context.context.mentioned_files or {}
+ local mentioned_files = context.get_context().mentioned_files or {}
local allowed, err_msg = util.check_prompt_allowed(config.prompt_guard, mentioned_files)
if not allowed then
vim.notify(err_msg or 'Prompts will be denied by prompt_guard', vim.log.levels.WARN)
@@ -138,7 +138,7 @@ M.send_message = Promise.async(function(prompt, opts)
return false
end
- local mentioned_files = context.context.mentioned_files or {}
+ local mentioned_files = context.get_context().mentioned_files or {}
local allowed, err_msg = util.check_prompt_allowed(config.prompt_guard, mentioned_files)
if not allowed then
@@ -182,7 +182,7 @@ Examples:
This matches the file:// URI format that the reference picker already parses from your responses, enabling automatic navigation.
]]
- params.parts = context.format_message(prompt, opts.context)
+ params.parts = context.format_message(prompt, opts.context):await()
M.before_run(opts)
local session_id = state.active_session.id
@@ -237,7 +237,8 @@ end)
---@param prompt string
function M.after_run(prompt)
context.unload_attachments()
- state.last_sent_context = vim.deepcopy(context.context)
+ state.last_sent_context = vim.deepcopy(context.get_context())
+ context.delta_context()
require('opencode.history').write(prompt)
M._abort_count = 0
end
diff --git a/lua/opencode/promise.lua b/lua/opencode/promise.lua
index 869b7ca6..345e93ba 100644
--- a/lua/opencode/promise.lua
+++ b/lua/opencode/promise.lua
@@ -13,6 +13,7 @@
---@field reject fun(self: Promise, err: any): Promise
---@field and_then fun(self: Promise, callback: fun(value: T): U | Promise | nil): Promise
---@field catch fun(self: Promise, error_callback: fun(err: any): any | Promise | nil): Promise
+---@field finally fun(self: Promise, callback: fun(): nil): Promise
---@field wait fun(self: Promise, timeout?: integer, interval?: integer): T
---@field peek fun(self: Promise): T
---@field is_resolved fun(self: Promise): boolean
@@ -184,6 +185,49 @@ function Promise:catch(error_callback)
return new_promise
end
+---Execute a callback regardless of whether the promise resolves or rejects
+---The callback is called without any arguments and its return value is ignored
+---@param callback fun(): nil
+---@return Promise
+function Promise:finally(callback)
+ local new_promise = Promise.new()
+
+ local handle_finally = function()
+ local ok, err = pcall(callback)
+ -- Ignore callback errors and result, finally doesn't change the promise chain
+ if not ok then
+ -- Log error but don't propagate it
+ vim.notify('Error in finally callback: ' .. tostring(err), vim.log.levels.WARN)
+ end
+ end
+
+ local handle_success = function(value)
+ handle_finally()
+ new_promise:resolve(value)
+ end
+
+ local handle_error = function(err)
+ handle_finally()
+ new_promise:reject(err)
+ end
+
+ if self._resolved and not self._error then
+ -- Promise already resolved successfully
+ local schedule_finally = vim.schedule_wrap(handle_success)
+ schedule_finally(self._value)
+ elseif self._resolved and self._error then
+ -- Promise already rejected
+ local schedule_finally = vim.schedule_wrap(handle_error)
+ schedule_finally(self._error)
+ else
+ -- Promise still pending, add callbacks
+ table.insert(self._then_callbacks, handle_success)
+ table.insert(self._catch_callbacks, handle_error)
+ end
+
+ return new_promise
+end
+
--- Synchronously wait for the promise to resolve or reject
--- This will block the main thread, so use with caution
--- But is useful for synchronous code paths that need the result
@@ -330,9 +374,11 @@ end
---@return fun(...): Promise
function Promise.async(fn)
return function(...)
+ -- Capture both args and count to handle nil values correctly
+ local n = select('#', ...)
local args = { ... }
return Promise.spawn(function()
- return fn(unpack(args))
+ return fn(unpack(args, 1, n))
end)
end
end
diff --git a/lua/opencode/quick_chat.lua b/lua/opencode/quick_chat.lua
new file mode 100644
index 00000000..966cf7d9
--- /dev/null
+++ b/lua/opencode/quick_chat.lua
@@ -0,0 +1,445 @@
+local context = require('opencode.context')
+local state = require('opencode.state')
+local config = require('opencode.config')
+local core = require('opencode.core')
+local util = require('opencode.util')
+local session = require('opencode.session')
+local Promise = require('opencode.promise')
+local CursorSpinner = require('opencode.quick_chat.spinner')
+
+local M = {}
+
+---@class OpencodeQuickChatRunningSession
+---@field buf integer Buffer handle
+---@field row integer Row position for spinner
+---@field col integer Column position for spinner
+---@field spinner CursorSpinner Spinner instance
+---@field timestamp integer Timestamp when session started
+---@field range table|nil Range information
+
+---@type table
+local running_sessions = {}
+
+--- Global keymaps that are active during quick chat sessions
+---@type table
+local active_global_keymaps = {}
+
+--- Creates a quick chat session title
+---@param buf integer Buffer handle
+---@return string title The session title
+local function create_session_title(buf)
+ local file_name = vim.api.nvim_buf_get_name(buf)
+ local relative_path = file_name ~= '' and vim.fn.fnamemodify(file_name, ':~:.') or 'untitled'
+ local line_num = vim.api.nvim_win_get_cursor(0)[1]
+ local timestamp = os.date('%H:%M:%S')
+
+ return string.format('[QuickChat] %s:%d (%s)', relative_path, line_num, timestamp)
+end
+
+--- Removes global keymaps for quick chat
+local function teardown_global_keymaps()
+ if not next(active_global_keymaps) then
+ return
+ end
+
+ for key, _ in pairs(active_global_keymaps) do
+ pcall(vim.keymap.del, { 'n', 'i' }, key)
+ end
+
+ active_global_keymaps = {}
+end
+
+--- Cancels all running quick chat sessions
+local function cancel_all_quick_chat_sessions()
+ for session_id, session_info in pairs(running_sessions) do
+ if state.api_client then
+ local ok, result = pcall(function()
+ return state.api_client:abort_session(session_id):wait()
+ end)
+
+ if not ok then
+ vim.notify('Quick chat abort error: ' .. vim.inspect(result), vim.log.levels.WARN)
+ end
+ end
+
+ if session_info and session_info.spinner then
+ session_info.spinner:stop()
+ end
+
+ if config.debug.quick_chat and not config.debug.quick_chat.keep_session then
+ state.api_client:delete_session(session_id):catch(function(err)
+ vim.notify('Error deleting quickchat session: ' .. vim.inspect(err), vim.log.levels.WARN)
+ end)
+ end
+
+ running_sessions[session_id] = nil
+ end
+
+ -- Teardown keymaps once at the end
+ teardown_global_keymaps()
+ vim.notify('Quick chat cancelled by user', vim.log.levels.WARN)
+end
+
+--- Sets up global keymaps for quick chat
+local function setup_global_keymaps()
+ if next(active_global_keymaps) then
+ return
+ end
+
+ local quick_chat_keymap = config.keymap.quick_chat or {}
+ if quick_chat_keymap.cancel then
+ vim.keymap.set(quick_chat_keymap.cancel.mode or { 'n', 'i' }, quick_chat_keymap.cancel[1], function()
+ cancel_all_quick_chat_sessions()
+ end, {
+ desc = quick_chat_keymap.cancel.desc or 'Cancel quick chat session',
+ silent = true,
+ })
+
+ active_global_keymaps[quick_chat_keymap.cancel[1]] = true
+ end
+end
+
+--- Helper to clean up session info and spinner
+---@param session_info table Session tracking info
+---@param session_id string Session ID
+---@param message string|nil Optional message to display
+local function cleanup_session(session_info, session_id, message)
+ if session_info and session_info.spinner then
+ session_info.spinner:stop()
+ end
+
+ if config.debug.quick_chat and not config.debug.quick_chat.keep_session then
+ state.api_client:delete_session(session_id):catch(function(err)
+ vim.notify('Error deleting quickchat session: ' .. vim.inspect(err), vim.log.levels.WARN)
+ end)
+ end
+
+ running_sessions[session_id] = nil
+
+ -- Check if there are no more running sessions and teardown global keymaps
+ if not next(running_sessions) then
+ teardown_global_keymaps()
+ end
+
+ if message then
+ vim.notify(message, vim.log.levels.WARN)
+ end
+end
+
+--- Extracts text from message parts
+---@param message OpencodeMessage Message object
+---@return string response_text
+local function extract_response_text(message)
+ if not message then
+ return ''
+ end
+
+ local response_text = ''
+ for _, part in ipairs(message.parts or {}) do
+ if part.type == 'text' and part.text then
+ response_text = response_text .. part.text
+ end
+ end
+
+ -- Remove code fences
+ response_text = response_text:gsub('```[^\n]*\n?', '') -- Remove opening code fence
+ response_text = response_text:gsub('\n?```', '') -- Remove closing code fence
+ response_text = response_text:gsub('`([^`\n]*)`', '%1') -- Remove inline code backticks but keep content
+
+ return response_text
+end
+
+--- Applies raw code response to buffer (simple replacement)
+---@param buf integer Buffer handle
+---@param response_text string The raw code response
+---@param row integer Row position (0-indexed)
+---@param range table|nil Range information { start = number, stop = number }
+---@return boolean success Whether the replacement was successful
+local function apply_raw_code_response(buf, response_text, row, range)
+ if response_text == '' then
+ return false
+ end
+
+ local lines = vim.split(response_text, '\n')
+
+ if range then
+ -- Replace the selected range
+ local start_line = math.floor(range.start) - 1 -- Convert to 0-indexed integer
+ local end_line = math.floor(range.stop) - 1 -- Convert to 0-indexed integer
+ vim.api.nvim_buf_set_lines(buf, start_line, end_line + 1, false, lines)
+ else
+ -- Replace current line
+ vim.api.nvim_buf_set_lines(buf, row, row + 1, false, lines)
+ end
+
+ return true
+end
+
+--- Processes response from quickchat session
+---@param session_info table Session tracking info
+---@param messages OpencodeMessage[] Session messages
+---@param range table|nil Range information
+---@return boolean success Whether the response was processed successfully
+local function process_response(session_info, messages, range)
+ local response_message = messages[#messages]
+ if #messages < 2 and (not response_message or response_message.info.role ~= 'assistant') then
+ return false
+ end
+ ---@cast response_message OpencodeMessage
+
+ local response_text = extract_response_text(response_message) or ''
+ if response_text == '' then
+ vim.notify('Quick chat: Received empty response from assistant', vim.log.levels.WARN)
+ return false
+ end
+
+ local success = apply_raw_code_response(session_info.buf, response_text, session_info.row, range)
+ if success then
+ local target = range and 'selection' or 'current line'
+ vim.notify(string.format('Quick chat: Replaced %s with generated code', target), vim.log.levels.INFO)
+ else
+ vim.notify('Quick chat: Failed to apply raw code response', vim.log.levels.WARN)
+ end
+
+ return success
+end
+
+--- Hook function called when a session is done thinking (no more pending messages)
+---@param active_session Session The session object
+local on_done = Promise.async(function(active_session)
+ if not (active_session.title and vim.startswith(active_session.title, '[QuickChat]')) then
+ return
+ end
+
+ local running_session = running_sessions[active_session.id]
+ if not running_session then
+ return
+ end
+
+ local messages = session.get_messages(active_session):await() --[[@as OpencodeMessage[] ]]
+ if not messages then
+ cleanup_session(running_session, active_session.id, 'Failed to update file with quick chat response')
+ return
+ end
+
+ local success = process_response(running_session, messages, running_session.range)
+ if success then
+ cleanup_session(running_session, active_session.id)
+ else
+ cleanup_session(running_session, active_session.id, 'Failed to update file with quick chat response')
+ end
+end)
+
+---@param message string|nil The message to validate
+---@return boolean valid
+---@return string|nil error_message
+local function validate_quick_chat_prerequisites(message)
+ local buf = vim.api.nvim_get_current_buf()
+ local win = vim.api.nvim_get_current_win()
+
+ if not buf or not win then
+ return false, 'Quick chat requires an active file buffer'
+ end
+
+ if not message or message == '' then
+ return false, 'Quick chat message cannot be empty'
+ end
+
+ return true
+end
+
+--- Creates context configuration for quick chat
+--- Optimized for minimal token usage while providing essential context
+---@param has_range boolean Whether a range is specified
+---@return OpencodeContextConfig context_opts
+local function create_context_config(has_range)
+ return {
+ enabled = true,
+ current_file = { enabled = false }, -- Disable full file content
+ cursor_data = { enabled = not has_range, context_lines = 10 }, -- Only cursor position when no selection
+ selection = { enabled = has_range }, -- Only selected text when range provided
+ diagnostics = {
+ enabled = true,
+ error = true,
+ warning = true,
+ info = false,
+ only_closest = true, -- Only closest diagnostics, not all file diagnostics
+ },
+ agents = { enabled = false }, -- No agent context needed
+ buffer = { enabled = false }, -- Disable full buffer content for token efficiency
+ git_diff = { enabled = false }, -- No git context needed
+ }
+end
+
+--- Generates instructions for raw code generation mode
+---@param context_config OpencodeContextConfig Context configuration
+---@return string[] instructions Array of instruction lines
+local function generate_raw_code_instructions(context_config)
+ local context_info = ''
+
+ if context_config.selection and context_config.selection.enabled then
+ context_info = 'Output ONLY the code to replace the [SELECTED CODE]. '
+ elseif context_config.cursor_data and context_config.cursor_data.enabled then
+ context_info = ' Output ONLY the code to insert/append at the [CURRENT LINE]. '
+ end
+
+ local buf = vim.api.nvim_get_current_buf()
+ local filetype = vim.bo[buf].filetype
+
+ return {
+ 'I want you to act as a senior ' .. filetype .. ' developer. ' .. context_info,
+ 'I will ask you specific questions.',
+ 'I want you to ALWAYS return valid RAW code ONLY ',
+ 'CRITICAL: NEVER add (codeblocks, explanations or any additional text). ',
+ 'Respect the current indentation and formatting of the existing code. ',
+ "If you can't respond with code, respond with nothing.",
+ }
+end
+
+--- Creates message parameters for quick chat
+---@param message string The user message
+---@param buf integer Buffer handle
+---@param range table|nil Range information
+---@param context_config OpencodeContextConfig Context configuration
+---@param options table Options including model and agent
+---@return table params Message parameters
+local create_message = Promise.async(function(message, buf, range, context_config, options)
+ local quick_chat_config = config.quick_chat or {}
+
+ local format_opts = { context_config = context_config }
+ if range then
+ format_opts.range = { start = range.start, stop = range.stop }
+ end
+
+ local result = context.format_quick_chat_message(message, context_config, format_opts):await()
+
+ local instructions = quick_chat_config.instructions or generate_raw_code_instructions(context_config)
+
+ local parts = {
+ { type = 'text', text = table.concat(instructions, '\n') },
+ { type = 'text', text = result.text },
+ }
+
+ local params = { parts = parts }
+
+ local current_model = core.initialize_current_model():await()
+ local target_model = options.model or quick_chat_config.default_model or current_model
+ if target_model then
+ local provider, model = target_model:match('^(.-)/(.+)$')
+ if provider and model then
+ params.model = { providerID = provider, modelID = model }
+ end
+ end
+
+ local target_agent = options.agent or quick_chat_config.default_agent or state.current_mode or config.default_mode
+ if target_agent then
+ params.agent = target_agent
+ end
+
+ return params
+end)
+
+--- Unified quick chat function
+---@param message string Optional custom message to use instead of default prompts
+---@param options {context_config?:OpencodeContextConfig, model?: string, agent?: string}|nil Optional configuration for context and behavior
+---@param range table|nil Optional range information { start = number, stop = number }
+---@return Promise
+M.quick_chat = Promise.async(function(message, options, range)
+ options = options or {}
+
+ local valid, error_msg = validate_quick_chat_prerequisites(message)
+ if not valid then
+ vim.notify(error_msg or 'Unknown error', vim.log.levels.ERROR)
+ return Promise.new():resolve(nil)
+ end
+
+ local buf = vim.api.nvim_get_current_buf()
+ local win = vim.api.nvim_get_current_win()
+ local cursor_pos = vim.api.nvim_win_get_cursor(win)
+ local row, col = cursor_pos[1] - 1, cursor_pos[2] -- Convert to 0-indexed
+ local spinner = CursorSpinner.new(buf, row, col)
+
+ local file_name = vim.api.nvim_buf_get_name(buf)
+ local mentioned_files = file_name ~= '' and { file_name } or {}
+ local allowed, err_msg = util.check_prompt_allowed(config.prompt_guard, mentioned_files)
+ if not allowed then
+ spinner:stop()
+ return Promise.new():reject(err_msg or 'Prompt denied by prompt_guard')
+ end
+
+ local title = create_session_title(buf)
+ local quick_chat_session = core.create_new_session(title):await()
+ if not quick_chat_session then
+ spinner:stop()
+ return Promise.new():reject('Failed to create quickchat session')
+ end
+
+ if config.debug.quick_chat and config.debug.quick_chat.set_active_session then
+ state.active_session = quick_chat_session
+ end
+
+ running_sessions[quick_chat_session.id] = {
+ buf = buf,
+ row = row,
+ col = col,
+ spinner = spinner,
+ timestamp = vim.uv.now(),
+ range = range,
+ }
+
+ -- Set up global keymaps for quick chat
+ setup_global_keymaps()
+
+ local context_config = vim.tbl_deep_extend('force', create_context_config(range ~= nil), options.context_config or {})
+ local params = create_message(message, buf, range, context_config, options):await()
+
+ local success, err = pcall(function()
+ state.api_client:create_message(quick_chat_session.id, params):await()
+ on_done(quick_chat_session):await()
+ end)
+
+ if not success then
+ spinner:stop()
+ running_sessions[quick_chat_session.id] = nil
+ vim.notify('Error in quick chat: ' .. vim.inspect(err), vim.log.levels.ERROR)
+ end
+end)
+
+--- Setup function to initialize quick chat functionality
+function M.setup()
+ local augroup = vim.api.nvim_create_augroup('OpenCodeQuickChat', { clear = true })
+
+ vim.api.nvim_create_autocmd('BufDelete', {
+ group = augroup,
+ callback = function(ev)
+ local buf = ev.buf
+ for session_id, session_info in pairs(running_sessions) do
+ if session_info.buf == buf then
+ ---@diagnostic disable-next-line: undefined-field
+ if session_info.spinner and session_info.spinner.stop then
+ ---@diagnostic disable-next-line: undefined-field
+ session_info.spinner:stop()
+ end
+ running_sessions[session_id] = nil
+ end
+ end
+ end,
+ })
+
+ vim.api.nvim_create_autocmd('VimLeavePre', {
+ group = augroup,
+ callback = function()
+ for _session_id, session_info in pairs(running_sessions) do
+ ---@diagnostic disable-next-line: undefined-field
+ if session_info.spinner and session_info.spinner.stop then
+ ---@diagnostic disable-next-line: undefined-field
+ session_info.spinner:stop()
+ end
+ end
+ running_sessions = {}
+ teardown_global_keymaps()
+ end,
+ })
+end
+
+return M
diff --git a/lua/opencode/quick_chat/spinner.lua b/lua/opencode/quick_chat/spinner.lua
new file mode 100644
index 00000000..fb389f36
--- /dev/null
+++ b/lua/opencode/quick_chat/spinner.lua
@@ -0,0 +1,160 @@
+local config = require('opencode.config')
+local Timer = require('opencode.ui.timer')
+
+---@class CursorSpinner
+---@field buf integer
+---@field row integer
+---@field col integer
+---@field ns_id integer
+---@field extmark_id integer|nil
+---@field highlight_extmark_id integer|nil
+---@field current_frame integer
+---@field timer table|nil
+---@field active boolean
+---@field frames string[]
+---@field float_win integer|nil
+---@field float_buf integer|nil
+local CursorSpinner = {}
+CursorSpinner.__index = CursorSpinner
+
+function CursorSpinner.new(buf, row, col)
+ local self = setmetatable({}, CursorSpinner)
+ self.buf = buf
+ self.row = row
+ self.col = col
+ self.ns_id = vim.api.nvim_create_namespace('opencode_quick_chat_spinner')
+ self.extmark_id = nil
+ self.highlight_extmark_id = nil
+ self.current_frame = 1
+ self.timer = nil
+ self.active = true
+ self.float_win = nil
+ self.float_buf = nil
+
+ self.frames = config.ui.loading_animation and config.ui.loading_animation.frames
+ or { '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏' }
+
+ self:create_float()
+ self:render()
+ self:start_timer()
+ return self
+end
+
+function CursorSpinner:create_float()
+ if not self.active or not vim.api.nvim_buf_is_valid(self.buf) then
+ return
+ end
+
+ self.float_buf = vim.api.nvim_create_buf(false, true)
+
+ local win_config = self:get_float_config()
+
+ self.float_win = vim.api.nvim_open_win(self.float_buf, false, win_config)
+
+ vim.api.nvim_set_option_value('winhl', 'Normal:Comment', { win = self.float_win })
+ vim.api.nvim_set_option_value('wrap', false, { win = self.float_win })
+end
+
+function CursorSpinner:get_cancel_key()
+ local quick_chat_keymap = config.keymap.quick_chat or {}
+ return quick_chat_keymap.cancel and quick_chat_keymap.cancel[1] or ''
+end
+
+function CursorSpinner:get_float_config()
+ local cancel_key = self:get_cancel_key()
+ local legend = ' ' .. cancel_key .. ' to cancel'
+ local spinner_width = 3
+ local legend_width = #legend
+ local total_width = spinner_width + legend_width + 1 -- +1 for spacing
+
+ return {
+ relative = 'cursor',
+ width = total_width,
+ height = 1,
+ row = 0,
+ col = 2, -- 2 columns to the right of cursor
+ style = 'minimal',
+ border = 'rounded',
+ focusable = false,
+ zindex = 1000,
+ }
+end
+
+function CursorSpinner:render()
+ if not self.active or not self.float_buf or not vim.api.nvim_buf_is_valid(self.float_buf) then
+ return
+ end
+
+ local spinner_part = ' ' .. self.frames[self.current_frame] .. ' '
+
+ local cancel_key = self:get_cancel_key() or ''
+ local legend_part = cancel_key and ' ' .. cancel_key .. ' to cancel' or ''
+
+ local content = spinner_part .. legend_part
+
+ vim.api.nvim_buf_set_lines(self.float_buf, 0, -1, false, { content })
+
+ if self.highlight_extmark_id and vim.api.nvim_buf_is_valid(self.float_buf) then
+ pcall(vim.api.nvim_buf_del_extmark, self.float_buf, self.ns_id, self.highlight_extmark_id)
+ end
+
+ if vim.api.nvim_buf_is_valid(self.float_buf) then
+ self.highlight_extmark_id = vim.api.nvim_buf_set_extmark(self.float_buf, self.ns_id, 0, #spinner_part + 1, {
+ end_col = #spinner_part + 1 + #cancel_key,
+ hl_group = 'WarningMsg',
+ })
+ end
+end
+
+function CursorSpinner:next_frame()
+ self.current_frame = (self.current_frame % #self.frames) + 1
+end
+
+function CursorSpinner:start_timer()
+ self.timer = Timer.new({
+ interval = 100, -- 10 FPS
+ on_tick = function()
+ if not self.active then
+ return false
+ end
+ self:next_frame()
+ self:render()
+ return true
+ end,
+ repeat_timer = true,
+ })
+ if self.timer then
+ self.timer:start()
+ end
+end
+
+function CursorSpinner:stop()
+ if not self.active then
+ return
+ end
+
+ self.active = false
+
+ if self.timer and self.timer.stop then
+ self.timer:stop()
+ self.timer = nil
+ end
+
+ if self.float_win and vim.api.nvim_win_is_valid(self.float_win) then
+ pcall(vim.api.nvim_win_close, self.float_win, true)
+ end
+
+ if self.float_buf and vim.api.nvim_buf_is_valid(self.float_buf) then
+ pcall(vim.api.nvim_buf_delete, self.float_buf, { force = true })
+ end
+
+ if self.extmark_id and vim.api.nvim_buf_is_valid(self.buf) then
+ pcall(vim.api.nvim_buf_del_extmark, self.buf, self.ns_id, self.extmark_id)
+ end
+
+ if self.highlight_extmark_id and vim.api.nvim_buf_is_valid(self.float_buf or self.buf) then
+ pcall(vim.api.nvim_buf_del_extmark, self.float_buf or self.buf, self.ns_id, self.highlight_extmark_id)
+ end
+end
+
+return CursorSpinner
diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua
index 26a323a9..1248eb27 100644
--- a/lua/opencode/types.lua
+++ b/lua/opencode/types.lua
@@ -86,6 +86,7 @@
---@field session_picker OpencodeSessionPickerKeymap
---@field timeline_picker OpencodeTimelinePickerKeymap
---@field history_picker OpencodeHistoryPickerKeymap
+---@field quick_chat OpencodeQuickChatKeymap
---@class OpencodeSessionPickerKeymap
---@field delete_session OpencodeKeymapEntry
@@ -100,6 +101,9 @@
---@field delete_entry OpencodeKeymapEntry
---@field clear_all OpencodeKeymapEntry
+---@class OpencodeQuickChatKeymap
+---@field cancel OpencodeKeymapEntry
+
---@class OpencodeCompletionFileSourcesConfig
---@field enabled boolean
---@field preferred_cli_tool 'server'|'fd'|'fdfind'|'rg'|'git'
@@ -147,16 +151,19 @@
---@class OpencodeContextConfig
---@field enabled boolean
----@field cursor_data { enabled: boolean }
----@field diagnostics { enabled:boolean, info: boolean, warning: boolean, error: boolean }
+---@field cursor_data { enabled: boolean, context_lines?: number }
+---@field diagnostics { enabled:boolean, info: boolean, warning: boolean, error: boolean, only_closest: boolean}
---@field current_file { enabled: boolean }
---@field selection { enabled: boolean }
---@field agents { enabled: boolean }
+---@field buffer { enabled: boolean }
+---@field git_diff { enabled: boolean }
---@class OpencodeDebugConfig
---@field enabled boolean
---@field capture_streamed_events boolean
---@field show_ids boolean
+---@field quick_chat {keep_session: boolean, set_active_session: boolean}
---@class OpencodeHooks
---@field on_file_edited? fun(file: string): nil
@@ -173,6 +180,11 @@
---@field setup fun(opts?: OpencodeConfig): nil
---@field get_key_for_function fun(scope: 'editor'|'input_window'|'output_window', function_name: string): string|nil
+---@class OpencodeQuickChatConfig
+---@field default_model? string -- Use current model if nil
+---@field default_agent? string -- Use current mode if nil
+---@field instructions? string[] -- Custom instructions for quick chat
+
---@class OpencodeConfig
---@field preferred_picker 'telescope' | 'fzf' | 'mini.pick' | 'snacks' | 'select' | nil
---@field preferred_completion 'blink' | 'nvim-cmp' | 'vim_complete' | nil -- Preferred completion strategy for mentons and commands
@@ -186,6 +198,7 @@
---@field prompt_guard? fun(mentioned_files: string[]): boolean
---@field hooks OpencodeHooks
---@field legacy_commands boolean
+---@field quick_chat OpencodeQuickChatConfig
---@class MessagePartState
---@field input TaskToolInput|BashToolInput|FileToolInput|TodoToolInput|GlobToolInput|GrepToolInput|WebFetchToolInput|ListToolInput Input data for the tool
@@ -290,6 +303,7 @@
---@field info MessageInfo Metadata about the message
---@field parts OpencodeMessagePart[] Parts that make up the message
---@field references CodeReference[]|nil Parsed file references from text parts (cached)
+---@field system string|nil System message content
---@class MessageInfo
---@field id string Unique message identifier
@@ -369,11 +383,14 @@
---@field line number
---@field column number
---@field line_content string
+---@field lines_before string[]
+---@field lines_after string[]
---@class OpencodeContextFile
---@field path string
---@field name string
---@field extension string
+---@field sent_at? number
---@class OpencodeMessagePartSourceText
---@field start number
diff --git a/lua/opencode/ui/completion/context.lua b/lua/opencode/ui/completion/context.lua
index e8ec2c9a..2656ca37 100644
--- a/lua/opencode/ui/completion/context.lua
+++ b/lua/opencode/ui/completion/context.lua
@@ -44,27 +44,26 @@ local function format_diagnostics(diagnostics)
return 'No diagnostics available.'
end
- local counts = {}
+ local by_severity = { Error = {}, Warning = {}, Info = {}, Hint = {} }
for _, diag in ipairs(diagnostics) do
- counts[diag.severity] = (counts[diag.severity] or 0) + 1
- end
- local parts = {}
- if counts[vim.diagnostic.severity.ERROR] then
- table.insert(parts, string.format('%d Error%s', counts[1], counts[1] > 1 and 's' or ''))
- end
- if counts[vim.diagnostic.severity.WARN] then
- table.insert(parts, string.format('%d Warning%s', counts[2], counts[2] > 1 and 's' or ''))
- end
- if counts[vim.diagnostic.severity.INFO] then
- table.insert(parts, string.format('%d Info%s', counts[3], counts[3] > 1 and 's' or ''))
+ local severity = diag.severity
+ local key = severity == 1 and 'Error' or severity == 2 and 'Warning' or severity == 3 and 'Info' or 'Hint'
+ table.insert(by_severity[key], diag)
end
- return table.concat(parts, ', ')
+ local summary = {}
+ for key, items in pairs(by_severity) do
+ if #items > 0 then
+ table.insert(summary, string.format('%d %s%s', #items, key, #items > 1 and 's' or ''))
+ end
+ end
+ return table.concat(summary, ', ')
end
local function format_selection(selection)
- local lang = selection.file and selection.file.extension or ''
- return string.format('```%s\n%s\n```', lang, selection.content)
+ local lang = (selection.file and selection.file.extension) or ''
+ local content = selection.content or ''
+ return string.format('```%s\n%s\n```', lang, content)
end
---@param cursor_data? OpencodeContextCursorData
@@ -77,14 +76,15 @@ local function format_cursor_data(cursor_data)
return 'No cursor data available.'
end
- local filetype = context.context.current_file and context.context.current_file.extension
- local parts = {
- 'Line: ' .. (cursor_data.line or 'N/A'),
- (cursor_data.column or ''),
- string.format('```%s \n%s\n```', filetype, cursor_data.line_content or 'N/A'),
- }
-
- return table.concat(parts, '\n')
+ local filetype = context.get_context().current_file and context.get_context().current_file.extension or ''
+ local content = cursor_data.line_content or ''
+ return string.format(
+ 'Line: %s\nColumn: %s\n```%s\n%s\n```',
+ tostring(cursor_data.line or 'N/A'),
+ tostring(cursor_data.column or 'N/A'),
+ filetype,
+ content
+ )
end
---@param ctx OpencodeContext
@@ -238,7 +238,7 @@ local context_source = {
return {}
end
- local ctx = context.delta_context()
+ local ctx = context.get_context()
local items = {
add_current_file_item(ctx),
diff --git a/lua/opencode/ui/completion/engines/base.lua b/lua/opencode/ui/completion/engines/base.lua
index 7f7ff261..9062d535 100644
--- a/lua/opencode/ui/completion/engines/base.lua
+++ b/lua/opencode/ui/completion/engines/base.lua
@@ -33,7 +33,7 @@ end
---Get trigger characters from config
---@return string[]
-function CompletionEngine:get_trigger_characters()
+function CompletionEngine.get_trigger_characters()
local config = require('opencode.config')
local mention_key = config.get_key_for_function('input_window', 'mention')
local slash_key = config.get_key_for_function('input_window', 'slash_commands')
@@ -49,7 +49,7 @@ end
---Default implementation checks if current buffer filetype is 'opencode'
---Child classes can override this to add engine-specific availability checks
---@return boolean true if the engine can be used in the current context
-function CompletionEngine:is_available()
+function CompletionEngine.is_available()
return vim.bo.filetype == 'opencode'
end
diff --git a/lua/opencode/ui/completion/files.lua b/lua/opencode/ui/completion/files.lua
index f37b3cf9..38ae0b70 100644
--- a/lua/opencode/ui/completion/files.lua
+++ b/lua/opencode/ui/completion/files.lua
@@ -91,7 +91,7 @@ local function create_file_item(file, suffix, priority)
local file_config = config.ui.completion.file_sources
local max_display_len = file_config.max_display_length or 50
if #display_label > max_display_len then
- display_label = '...' .. display_label:sub(-(max_display_len - 3))
+ display_label = '...' .. display_label:sub(-(math.floor(max_display_len) - 3))
end
local kind = vim.endswith(file, '/') and 'folder' or 'file'
return {
diff --git a/lua/opencode/ui/context_bar.lua b/lua/opencode/ui/context_bar.lua
index 9b6bcd2d..eff0061b 100644
--- a/lua/opencode/ui/context_bar.lua
+++ b/lua/opencode/ui/context_bar.lua
@@ -3,6 +3,7 @@ local M = {}
local context = require('opencode.context')
local icons = require('opencode.ui.icons')
local state = require('opencode.state')
+local config = require('opencode.config')
local prompt_guard_indicator = require('opencode.ui.prompt_guard_indicator')
local function get_current_file_info(ctx)
@@ -46,15 +47,20 @@ local function format_cursor_data(ctx)
end
local function create_winbar_segments()
- local ctx = context.delta_context()
+ local ctx = context.get_context()
local segments = {}
local current_file = get_current_file_info(ctx)
if context.is_context_enabled('current_file') and current_file then
+ local highlight = 'OpencodeContextCurrentFile'
+ if ctx.current_file and ctx.current_file.sent_at then
+ highlight = 'OpencodeContextCurrentFileNotUpdated'
+ end
+
table.insert(segments, {
icon = icons.get('attached_file'),
text = current_file.name,
- highlight = 'OpencodeContextCurrentFile',
+ highlight = highlight,
})
end
@@ -106,18 +112,18 @@ local function create_winbar_segments()
counts[type_name] = (counts[type_name] or 0) + 1
end
+ local filter_icon = config.context.diagnostics.only_closest and icons.get('filter') or ''
for _, type_name in pairs(severity_types) do
local count = counts[type_name]
if count and count > 0 then
table.insert(segments, {
icon = icons.get(type_name),
- text = '(' .. count .. ')',
+ text = '(' .. count .. filter_icon .. ')',
highlight = 'OpencodeContext' .. type_name:gsub('^%l', string.upper),
})
end
end
end
-
return segments
end
diff --git a/lua/opencode/ui/highlight.lua b/lua/opencode/ui/highlight.lua
index f0d87462..1dc4b421 100644
--- a/lua/opencode/ui/highlight.lua
+++ b/lua/opencode/ui/highlight.lua
@@ -26,6 +26,7 @@ function M.setup()
vim.api.nvim_set_hl(0, 'OpencodeContextBar', { fg = '#3b4261', default = true })
vim.api.nvim_set_hl(0, 'OpencodeContextFile', { link = '@label', default = true })
vim.api.nvim_set_hl(0, 'OpencodeContextCurrentFile', { link = 'OpencodeContext', default = true })
+ vim.api.nvim_set_hl(0, 'OpencodeContextCurrentFileNotUpdated', { link = 'Comment', default = true })
vim.api.nvim_set_hl(0, 'OpencodeContextAgent', { link = '@label', default = true })
vim.api.nvim_set_hl(0, 'OpencodeContextSelection', { link = '@label', default = true })
vim.api.nvim_set_hl(0, 'OpencodeContextError', { link = '@label', default = true })
@@ -59,6 +60,7 @@ function M.setup()
vim.api.nvim_set_hl(0, 'OpencodeContextBar', { fg = '#3b4261', default = true })
vim.api.nvim_set_hl(0, 'OpencodeContextFile', { link = '@label', default = true })
vim.api.nvim_set_hl(0, 'OpencodeContextCurrentFile', { link = '@label', default = true })
+ vim.api.nvim_set_hl(0, 'OpencodeContextCurrentFileNotUpdated', { link = 'Comment', default = true })
vim.api.nvim_set_hl(0, 'OpencodeContextAgent', { link = '@label', default = true })
vim.api.nvim_set_hl(0, 'OpencodeContextSelection', { link = '@label', default = true })
vim.api.nvim_set_hl(0, 'OpencodeContextError', { link = 'DiagnosticError', default = true })
diff --git a/lua/opencode/ui/icons.lua b/lua/opencode/ui/icons.lua
index b110c1d0..53baf91f 100644
--- a/lua/opencode/ui/icons.lua
+++ b/lua/opencode/ui/icons.lua
@@ -38,6 +38,7 @@ local presets = {
error = ' ',
warning = ' ',
info = ' ',
+ filter = '/',
selection = ' ',
command = ' ',
},
@@ -74,6 +75,7 @@ local presets = {
error = '[E]',
warning = '[W]',
info = '[I] ',
+ filter = '/*',
selection = "'<'> ",
command = '::',
},
diff --git a/lua/opencode/ui/prompt_guard_indicator.lua b/lua/opencode/ui/prompt_guard_indicator.lua
index 546ab83c..fbdf1653 100644
--- a/lua/opencode/ui/prompt_guard_indicator.lua
+++ b/lua/opencode/ui/prompt_guard_indicator.lua
@@ -9,7 +9,7 @@ local context = require('opencode.context')
---@return boolean allowed
---@return string|nil error_message
function M.get_status()
- local mentioned_files = context.context.mentioned_files or {}
+ local mentioned_files = context.get_context().mentioned_files or {}
return util.check_prompt_allowed(config.prompt_guard, mentioned_files)
end
diff --git a/lua/opencode/util.lua b/lua/opencode/util.lua
index de7d1424..bbfff2d6 100644
--- a/lua/opencode/util.lua
+++ b/lua/opencode/util.lua
@@ -134,7 +134,12 @@ function M.is_git_project()
if _is_git_project ~= nil then
return _is_git_project
end
- local git_dir = Path:new(vim.fn.getcwd()):joinpath('.git')
+ local cwd = vim.fn.getcwd()
+ if not cwd then
+ _is_git_project = false
+ return _is_git_project
+ end
+ local git_dir = Path:new(cwd):joinpath('.git')
_is_git_project = git_dir:exists()
return _is_git_project
end
@@ -263,14 +268,16 @@ function M.parse_dot_args(args_str)
t[parts[i]] = t[parts[i]] or {}
t = t[parts[i]]
end
+ -- Convert string values to appropriate types
+ local parsed_value = value
if value == 'true' then
- value = true
+ parsed_value = true
elseif value == 'false' then
- value = false
+ parsed_value = false
elseif tonumber(value) then
- value = tonumber(value)
+ parsed_value = tonumber(value)
end
- t[parts[#parts]] = value
+ t[parts[#parts]] = parsed_value
end
end
return result
@@ -344,6 +351,7 @@ end
--- Parse run command arguments with optional agent, model, and context prefixes.
--- Returns opts table and remaining prompt string.
--- Format: [agent=] [model=] [context=]
+--- Also supports quick context syntax like "#buffer #git_diff" in the prompt
--- @param args string[]
--- @return table opts, string prompt
function M.parse_run_args(args)
@@ -372,6 +380,13 @@ function M.parse_run_args(args)
local prompt_tokens = vim.list_slice(args, prompt_start_idx)
local prompt = table.concat(prompt_tokens, ' ')
+ if prompt:find('#') then
+ local cleaned_prompt, quick_context = M.parse_quick_context_args(prompt)
+ prompt = cleaned_prompt
+
+ opts.context = vim.tbl_deep_extend('force', opts.context or {}, quick_context) --[[@as OpencodeContextConfig]]
+ end
+
return opts, prompt
end
@@ -410,4 +425,105 @@ function M.is_temp_path(path, pattern)
return true
end
+--- Parse quick context arguments and extract prompt.
+--- Transforms quick context items like "generate a conventional commit #git_diff #buffer"
+--- into a partial ContextConfig object with only enabled fields and returns the remaining text as prompt.
+--- @param prompt string Context arguments string (e.g., "generate a conventional commit #buffer #git_diff")
+--- @return string prompt, OpencodeContextConfig config
+function M.parse_quick_context_args(prompt)
+ ---@type OpencodeContextConfig
+ local config = { enabled = true }
+
+ if not prompt or prompt == '' then
+ return '', config
+ end
+
+ local function extract(items)
+ local found = false
+ for _, item in ipairs(items) do
+ local pattern = '#' .. item
+ local start_pos = prompt:lower():find(pattern:lower(), 1, true)
+ if start_pos then
+ found = true
+ local end_pos = start_pos + #pattern - 1
+ prompt = prompt:sub(1, start_pos - 1) .. prompt:sub(end_pos + 1)
+ end
+ end
+ return found
+ end
+
+ local cursor_enabled = extract({ 'cursor_data', 'cursor' })
+ if cursor_enabled then
+ config.cursor_data = { enabled = true, context_lines = 5 }
+ end
+
+ local info_enabled = extract({ 'info' })
+ local warning_enabled = extract({ 'warnings', 'warning', 'warn' })
+ local error_enabled = extract({ 'errors' })
+
+ if info_enabled or warning_enabled or error_enabled then
+ config.diagnostics = { enabled = true, only_closest = true }
+ if info_enabled then
+ config.diagnostics.info = true
+ end
+ if warning_enabled then
+ config.diagnostics.warning = true
+ end
+ if error_enabled then
+ config.diagnostics.error = true
+ end
+ end
+
+ local current_file_enabled = extract({ 'current_file', 'file' })
+ if current_file_enabled then
+ config.current_file = { enabled = true }
+ end
+
+ local selection_enabled = extract({ 'selection' })
+ if selection_enabled then
+ config.selection = { enabled = true }
+ end
+
+ local agents_enabled = extract({ 'agents' })
+ if agents_enabled then
+ config.agents = { enabled = true }
+ end
+
+ local buffer_enabled = extract({ 'buffer' })
+ if buffer_enabled then
+ config.buffer = { enabled = true }
+ end
+
+ local git_diff_enabled = extract({ 'git_diff', 'diff' })
+ if git_diff_enabled then
+ config.git_diff = { enabled = true }
+ end
+
+ return vim.trim(prompt:gsub('%s+', ' ')), config
+end
+
+function M.get_visual_range()
+ if not vim.fn.mode():match('[vV\022]') then
+ return nil
+ end
+ vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes('', true, false, true), 'nx', true)
+
+ local bufnr = vim.api.nvim_get_current_buf()
+ local start_pos = vim.fn.getpos("'<")
+ local end_pos = vim.fn.getpos("'>")
+
+ local start_line = start_pos[2]
+ local start_col = start_pos[3]
+ local end_line = end_pos[2]
+ local end_col = end_pos[3]
+
+ return {
+ bufnr = bufnr,
+ start_line = start_line,
+ start_col = start_col,
+ end_line = end_line,
+ end_col = end_col,
+ }
+end
+
return M
diff --git a/test.txt b/test.txt
deleted file mode 100644
index 792b5ae7..00000000
--- a/test.txt
+++ /dev/null
@@ -1 +0,0 @@
-Received.
\ No newline at end of file
diff --git a/tests/unit/context_bar_spec.lua b/tests/unit/context_bar_spec.lua
index 35120b59..2f4b51c5 100644
--- a/tests/unit/context_bar_spec.lua
+++ b/tests/unit/context_bar_spec.lua
@@ -2,10 +2,12 @@ local context_bar = require('opencode.ui.context_bar')
local context = require('opencode.context')
local state = require('opencode.state')
local icons = require('opencode.ui.icons')
+local config = require('opencode.config')
local assert = require('luassert')
describe('opencode.ui.context_bar', function()
local original_delta_context
+ local original_get_context
local original_is_context_enabled
local original_get_icon
local original_subscribe
@@ -32,6 +34,7 @@ describe('opencode.ui.context_bar', function()
before_each(function()
original_delta_context = context.delta_context
+ original_get_context = context.get_context
original_is_context_enabled = context.is_context_enabled
original_get_icon = icons.get
original_subscribe = state.subscribe
@@ -55,6 +58,10 @@ describe('opencode.ui.context_bar', function()
return mock_context
end
+ context.get_context = function()
+ return mock_context
+ end
+
context.is_context_enabled = function(_)
return true -- Enable all context types by default
end
@@ -96,6 +103,7 @@ describe('opencode.ui.context_bar', function()
after_each(function()
context.delta_context = original_delta_context
+ context.get_context = original_get_context
context.is_context_enabled = original_is_context_enabled
icons.get = original_get_icon
state.subscribe = original_subscribe
@@ -135,6 +143,25 @@ describe('opencode.ui.context_bar', function()
assert.is_not_nil(winbar_capture.value:find(icons.get('attached_file') .. 'test%.lua'))
end)
+ it('renders current file with dimmed highlight when already sent', function()
+ mock_context.current_file = {
+ name = 'test.lua',
+ path = '/tmp/test.lua',
+ sent_at = 1234567890000, -- File has been sent
+ }
+
+ local mock_input_win = 2002
+ local winbar_capture = create_mock_window(mock_input_win)
+
+ state.windows = { input_win = mock_input_win }
+ context_bar.render()
+
+ assert.is_string(winbar_capture.value)
+ assert.is_not_nil(winbar_capture.value:find(icons.get('attached_file') .. 'test%.lua'))
+ -- Check that Comment highlight is used for already-sent files
+ assert.is_not_nil(winbar_capture.value:find('%%#OpencodeContextCurrentFileNotUpdated#'))
+ end)
+
it('renders winbar with multiple context elements', function()
mock_context.current_file = { name = 'main.lua', path = '/src/main.lua' }
mock_context.mentioned_files = { '/file1.lua', '/file2.lua' }
@@ -155,7 +182,9 @@ describe('opencode.ui.context_bar', function()
assert.is_not_nil(winbar_capture.value:find('L:10')) -- Cursor data
end)
- it('renders winbar with diagnostics', function()
+ it('renders winbar with all diagnostics', function()
+ local original_only_closest = config.context.diagnostics.only_closest
+ config.context.diagnostics.only_closest = false
mock_context.linter_errors = {
{ severity = 1 }, -- ERROR
{ severity = 1 }, -- ERROR
@@ -173,6 +202,30 @@ describe('opencode.ui.context_bar', function()
assert.is_not_nil(winbar_capture.value:find(icons.get('error') .. '%(2%)')) -- 2 errors
assert.is_not_nil(winbar_capture.value:find(icons.get('warning') .. '%(1%)')) -- Warning icon
assert.is_not_nil(winbar_capture.value:find(icons.get('info') .. '%(1%)')) -- Info icon
+ config.context.diagnostics.only_closest = original_only_closest
+ end)
+
+ it('renders winbar with filtered diagnostics', function()
+ local original_only_closest = config.context.diagnostics.only_closest
+ config.context.diagnostics.only_closest = true
+ mock_context.linter_errors = {
+ { severity = 1 }, -- ERROR
+ { severity = 1 }, -- ERROR
+ { severity = 2 }, -- WARN
+ { severity = 3 }, -- INFO
+ }
+
+ local mock_input_win = 2004
+ local winbar_capture = create_mock_window(mock_input_win)
+
+ state.windows = { input_win = mock_input_win }
+ context_bar.render()
+
+ assert.is_string(winbar_capture.value)
+ assert.is_not_nil(winbar_capture.value:find(icons.get('error') .. '%(2' .. icons.get('filter') .. '%)')) -- 2 errors
+ assert.is_not_nil(winbar_capture.value:find(icons.get('warning') .. '%(1' .. icons.get('filter') .. '%)')) -- Warning icon
+ assert.is_not_nil(winbar_capture.value:find(icons.get('info') .. '%(1' .. icons.get('filter') .. '%)')) -- Info icon
+ config.context.diagnostics.only_closest = original_only_closest
end)
it('respects context enabled settings', function()
diff --git a/tests/unit/context_completion_spec.lua b/tests/unit/context_completion_spec.lua
index a95de397..5b00b539 100644
--- a/tests/unit/context_completion_spec.lua
+++ b/tests/unit/context_completion_spec.lua
@@ -41,35 +41,41 @@ describe('context completion', function()
}
mock_context = {
- is_context_enabled = function(context_type)
- return mock_config.context[context_type] and mock_config.context[context_type].enabled or false
+ is_context_enabled = function()
+ return true
end,
delta_context = function()
+ return {}
+ end,
+ add_file = function() end,
+ add_subagent = function() end,
+ add_selection = function() end,
+ remove_file = function() end,
+ remove_subagent = function() end,
+ remove_selection = function() end,
+ get_context = function()
return {
- current_file = { path = '/test/file.lua', name = 'file.lua', extension = 'lua' },
- mentioned_files = { '/test/other.lua', '/test/helper.js' },
- selections = {
- {
- file = { name = 'test.lua', extension = 'lua' },
+ current_file = { extension = 'lua', name = 'test.lua', path = '/test/test.lua' },
+ selections = {
+ {
content = 'local x = 1',
- lines = '1-1',
- },
+ file = { extension = 'lua', name = 'test.lua' },
+ lines = '1-1'
+ }
},
- mentioned_subagents = { 'review', 'test' },
+ mentioned_files = { '/path/to/file1.lua', '/path/to/file2.lua' },
+ mentioned_subagents = { 'review', 'analyze' },
linter_errors = {
- { severity = _G.vim.diagnostic.severity.ERROR },
- { severity = _G.vim.diagnostic.severity.WARN },
+ { severity = 1, msg = 'Test error message', pos = '1:10' },
+ { severity = 2, msg = 'Test warning message', pos = '2:15' }
},
cursor_data = {
line = 42,
column = 10,
- line_content = 'local test = "hello"',
- },
+ line_content = 'local test = "hello"'
+ }
}
end,
- remove_file = function() end,
- remove_subagent = function() end,
- remove_selection = function() end,
context = {
current_file = { extension = 'lua' },
},
diff --git a/tests/unit/context_spec.lua b/tests/unit/context_spec.lua
index de0204ee..c12d33a3 100644
--- a/tests/unit/context_spec.lua
+++ b/tests/unit/context_spec.lua
@@ -50,33 +50,47 @@ end)
describe('format_message', function()
local original_delta_context
+ local original_get_context
+ local mock_context
+
before_each(function()
- context.context.current_file = nil
- context.context.mentioned_files = nil
- context.context.mentioned_subagents = nil
- context.context.selections = nil
- context.context.linter_errors = nil
- context.context.cursor_data = nil
+ mock_context = {
+ current_file = nil,
+ mentioned_files = nil,
+ mentioned_subagents = nil,
+ selections = nil,
+ linter_errors = nil,
+ cursor_data = nil,
+ }
+
original_delta_context = context.delta_context
+ original_get_context = context.get_context
+
+ context.get_context = function()
+ return mock_context
+ end
+
context.delta_context = function()
- return context.context
+ return context.get_context()
end
end)
after_each(function()
context.delta_context = original_delta_context
+ context.get_context = original_get_context
end)
it('returns a parts array with prompt as first part', function()
- local parts = context.format_message('hello world')
+ local parts = context.format_message('hello world'):wait()
assert.is_table(parts)
assert.equal('hello world', parts[1].text)
assert.equal('text', parts[1].type)
end)
it('includes mentioned_files and subagents', function()
- context.context.mentioned_files = { '/tmp/foo.lua' }
- context.context.mentioned_subagents = { 'agent1' }
- local parts = context.format_message('prompt @foo.lua @agent1')
+ local ChatContext = require('opencode.context.chat_context')
+ ChatContext.context.mentioned_files = { '/tmp/foo.lua' }
+ ChatContext.context.mentioned_subagents = { 'agent1' }
+ local parts = context.format_message('prompt @foo.lua @agent1'):wait()
assert.is_true(#parts > 2)
local found_file, found_agent = false, false
for _, p in ipairs(parts) do
@@ -93,16 +107,38 @@ describe('format_message', function()
end)
describe('delta_context', function()
+ local mock_context
+ local original_get_context
+
+ before_each(function()
+ mock_context = {
+ current_file = nil,
+ mentioned_files = nil,
+ mentioned_subagents = nil,
+ selections = nil,
+ linter_errors = nil,
+ cursor_data = nil,
+ }
+
+ original_get_context = context.get_context
+ context.get_context = function()
+ return mock_context
+ end
+ end)
+
+ after_each(function()
+ context.get_context = original_get_context
+ end)
it('removes current_file if unchanged', function()
local file = { name = 'foo.lua', path = '/tmp/foo.lua', extension = 'lua' }
- context.context.current_file = vim.deepcopy(file)
- state.last_sent_context = { current_file = context.context.current_file }
+ mock_context.current_file = vim.deepcopy(file)
+ state.last_sent_context = { current_file = mock_context.current_file }
local result = context.delta_context()
assert.is_nil(result.current_file)
end)
it('removes mentioned_subagents if unchanged', function()
local subagents = { 'a' }
- context.context.mentioned_subagents = vim.deepcopy(subagents)
+ mock_context.mentioned_subagents = vim.deepcopy(subagents)
state.last_sent_context = { mentioned_subagents = vim.deepcopy(subagents) }
local result = context.delta_context()
assert.is_nil(result.mentioned_subagents)
@@ -110,11 +146,26 @@ describe('delta_context', function()
end)
describe('add_file/add_selection/add_subagent', function()
+ local ChatContext = require('opencode.context.chat_context')
+ local original_context
+
before_each(function()
- context.context.mentioned_files = nil
- context.context.selections = nil
+ -- Store original context
+ original_context = vim.deepcopy(ChatContext.context)
+
+ -- Reset to clean state
+ ChatContext.context.mentioned_files = {}
+ ChatContext.context.selections = {}
+ ChatContext.context.mentioned_subagents = {}
+
context.delta_context()
- context.context.mentioned_subagents = nil
+ end)
+
+ after_each(function()
+ -- Restore original context
+ for k, v in pairs(original_context) do
+ ChatContext.context[k] = v
+ end
end)
it('adds a file if filereadable', function()
vim.fn.filereadable = function()
@@ -127,7 +178,7 @@ describe('add_file/add_selection/add_subagent', function()
end
context.add_file('/tmp/foo.lua')
- assert.same({ '/tmp/foo.lua' }, context.context.mentioned_files)
+ assert.same({ '/tmp/foo.lua' }, context.get_context().mentioned_files)
util.is_path_in_cwd = original_is_path_in_cwd
end)
@@ -136,14 +187,322 @@ describe('add_file/add_selection/add_subagent', function()
return 0
end
context.add_file('/tmp/bar.lua')
- assert.same({}, context.context.mentioned_files)
+ assert.same({}, context.get_context().mentioned_files)
end)
it('adds a selection', function()
context.add_selection({ foo = 'bar' })
- assert.same({ { foo = 'bar' } }, context.context.selections)
+ assert.same({ { foo = 'bar' } }, context.get_context().selections)
end)
it('adds a subagent', function()
context.add_subagent('agentX')
- assert.same({ 'agentX' }, context.context.mentioned_subagents)
+ assert.same({ 'agentX' }, context.get_context().mentioned_subagents)
+ end)
+end)
+
+describe('context static API with config override', function()
+ it('should use override config for context enabled checks', function()
+ local override_config = {
+ current_file = { enabled = false },
+ diagnostics = { enabled = false },
+ selection = { enabled = true },
+ agents = { enabled = true },
+ }
+
+ -- Test using static API with config parameter
+ assert.is_false(context.is_context_enabled('current_file', override_config))
+ assert.is_false(context.is_context_enabled('diagnostics', override_config))
+ assert.is_true(context.is_context_enabled('selection', override_config))
+ assert.is_true(context.is_context_enabled('agents', override_config))
+ end)
+
+ it('should fall back to global config when override not provided', function()
+ local override_config = {
+ current_file = { enabled = false },
+ -- other context types not specified
+ }
+
+ -- Test using static API with partial config
+ assert.is_false(context.is_context_enabled('current_file', override_config))
+
+ -- other context types should fall back to normal behavior
+ -- (these will use global config + state, tested elsewhere)
+ end)
+
+ it('should work without any override config', function()
+ -- Should behave exactly like global context using static API
+ assert.is_not_nil(context.is_context_enabled('current_file'))
+ assert.is_not_nil(context.is_context_enabled('diagnostics'))
+ end)
+end)
+
+describe('get_diagnostics with chat context selections', function()
+ local ChatContext
+
+ before_each(function()
+ ChatContext = require('opencode.context.chat_context')
+ -- Reset chat context
+ ChatContext.context = {
+ mentioned_files = {},
+ selections = {},
+ mentioned_subagents = {},
+ current_file = nil,
+ cursor_data = nil,
+ linter_errors = nil,
+ }
+ end)
+
+ it('should use chat context selection range when no explicit range provided', function()
+ -- Add a mock selection to chat context
+ local mock_selection = {
+ file = { path = '/tmp/test.lua', name = 'test.lua', extension = 'lua' },
+ content = 'print("hello")',
+ lines = '5, 8' -- Lines 5 to 8 (1-based)
+ }
+ ChatContext.add_selection(mock_selection)
+
+ -- Mock the BaseContext.get_diagnostics to capture the range parameter
+ local BaseContext = require('opencode.context.base_context')
+ local captured_range = nil
+ local original_get_diagnostics = BaseContext.get_diagnostics
+ BaseContext.get_diagnostics = function(buf, context_config, range)
+ captured_range = range
+ return {}
+ end
+
+ -- Call get_diagnostics without an explicit range
+ ChatContext.get_diagnostics(1, nil, nil)
+
+ -- Verify that a list of ranges was passed to base_context
+ assert.is_not_nil(captured_range)
+ assert.equal('table', type(captured_range))
+ assert.equal(1, #captured_range) -- Should have one range in the list
+ assert.equal(4, captured_range[1].start_line) -- 5 - 1 (0-based)
+ assert.equal(7, captured_range[1].end_line) -- 8 - 1 (0-based)
+
+ -- Restore original function
+ BaseContext.get_diagnostics = original_get_diagnostics
+ end)
+
+ it('should prioritize explicit range over chat context selections', function()
+ -- Add a mock selection to chat context
+ local mock_selection = {
+ file = { path = '/tmp/test.lua', name = 'test.lua', extension = 'lua' },
+ content = 'print("hello")',
+ lines = '5, 8'
+ }
+ ChatContext.add_selection(mock_selection)
+
+ -- Mock the BaseContext.get_diagnostics to capture the range parameter
+ local BaseContext = require('opencode.context.base_context')
+ local captured_range = nil
+ local original_get_diagnostics = BaseContext.get_diagnostics
+ BaseContext.get_diagnostics = function(buf, context_config, range)
+ captured_range = range
+ return {}
+ end
+
+ -- Call get_diagnostics with an explicit range
+ local explicit_range = { start_line = 10, end_line = 15 }
+ ChatContext.get_diagnostics(1, nil, explicit_range)
+
+ -- Verify that the explicit range was used, not the selection range
+ assert.is_not_nil(captured_range)
+ assert.equal(10, captured_range.start_line)
+ assert.equal(15, captured_range.end_line)
+
+ -- Restore original function
+ BaseContext.get_diagnostics = original_get_diagnostics
+ end)
+
+ it('should handle dash-separated line format in selections', function()
+ -- Add a mock selection with dash format (used by range-based selections)
+ local mock_selection = {
+ file = { path = '/tmp/test.lua', name = 'test.lua', extension = 'lua' },
+ content = 'print("hello")',
+ lines = '3-7' -- Lines 3 to 7 with dash separator
+ }
+ ChatContext.add_selection(mock_selection)
+
+ -- Mock the BaseContext.get_diagnostics to capture the range parameter
+ local BaseContext = require('opencode.context.base_context')
+ local captured_range = nil
+ local original_get_diagnostics = BaseContext.get_diagnostics
+ BaseContext.get_diagnostics = function(buf, context_config, range)
+ captured_range = range
+ return {}
+ end
+
+ -- Call get_diagnostics without an explicit range
+ ChatContext.get_diagnostics(1, nil, nil)
+
+ -- Verify that a list of ranges was passed and parsed correctly from dash format
+ assert.is_not_nil(captured_range)
+ assert.equal('table', type(captured_range))
+ assert.equal(1, #captured_range) -- Should have one range in the list
+ assert.equal(2, captured_range[1].start_line) -- 3 - 1 (0-based)
+ assert.equal(6, captured_range[1].end_line) -- 7 - 1 (0-based)
+
+ -- Restore original function
+ BaseContext.get_diagnostics = original_get_diagnostics
+ end)
+
+ it('should fallback to cursor behavior when no selections exist', function()
+ -- Ensure no selections in chat context
+ ChatContext.clear_selections()
+
+ -- Mock the BaseContext.get_diagnostics to capture the range parameter
+ local BaseContext = require('opencode.context.base_context')
+ local captured_range = nil
+ local original_get_diagnostics = BaseContext.get_diagnostics
+ BaseContext.get_diagnostics = function(buf, context_config, range)
+ captured_range = range
+ return {}
+ end
+
+ -- Call get_diagnostics without an explicit range
+ ChatContext.get_diagnostics(1, nil, nil)
+
+ -- Verify that no range was passed (should fallback to cursor behavior)
+ assert.is_nil(captured_range)
+
+ -- Restore original function
+ BaseContext.get_diagnostics = original_get_diagnostics
+ end)
+
+ it('should collect diagnostics from all selection ranges individually', function()
+ -- Add multiple selections to chat context
+ local selection1 = {
+ file = { path = '/tmp/test1.lua', name = 'test1.lua', extension = 'lua' },
+ content = 'print("first")',
+ lines = '3, 5'
+ }
+ local selection2 = {
+ file = { path = '/tmp/test2.lua', name = 'test2.lua', extension = 'lua' },
+ content = 'print("second")',
+ lines = '10, 12'
+ }
+ local selection3 = {
+ file = { path = '/tmp/test3.lua', name = 'test3.lua', extension = 'lua' },
+ content = 'print("third")',
+ lines = '7, 8'
+ }
+ ChatContext.add_selection(selection1)
+ ChatContext.add_selection(selection2)
+ ChatContext.add_selection(selection3)
+
+ -- Mock the BaseContext.get_diagnostics to capture the range parameter
+ local BaseContext = require('opencode.context.base_context')
+ local captured_range = nil
+ local original_get_diagnostics = BaseContext.get_diagnostics
+ BaseContext.get_diagnostics = function(buf, context_config, range)
+ captured_range = range
+ -- Return mock diagnostics for all ranges
+ if range and type(range) == 'table' and range[1] then
+ local result = {}
+ for i, r in ipairs(range) do
+ table.insert(result, {
+ lnum = r.start_line,
+ col = 0,
+ message = 'Mock diagnostic for range ' .. r.start_line .. '-' .. r.end_line,
+ severity = 1
+ })
+ end
+ return result
+ end
+ return {}
+ end
+
+ -- Call get_diagnostics without an explicit range
+ local result = ChatContext.get_diagnostics(1, nil, nil)
+
+ -- Verify that a single range list was passed containing all selections
+ assert.is_not_nil(captured_range)
+ assert.equal('table', type(captured_range))
+ assert.equal(3, #captured_range) -- Should have three ranges in the list
+
+ -- Check each range matches the selections
+ assert.equal(2, captured_range[1].start_line) -- 3 - 1 (0-based)
+ assert.equal(4, captured_range[1].end_line) -- 5 - 1 (0-based)
+
+ assert.equal(9, captured_range[2].start_line) -- 10 - 1 (0-based)
+ assert.equal(11, captured_range[2].end_line) -- 12 - 1 (0-based)
+
+ assert.equal(6, captured_range[3].start_line) -- 7 - 1 (0-based)
+ assert.equal(7, captured_range[3].end_line) -- 8 - 1 (0-based)
+
+ -- Verify that all diagnostics from all ranges are combined in the result
+ assert.equal(3, #result)
+
+ -- Restore original function
+ BaseContext.get_diagnostics = original_get_diagnostics
+ end)
+
+ it('should handle mixed line formats in multiple selection ranges', function()
+ -- Add selections with different line formats
+ local selection1 = {
+ file = { path = '/tmp/test.lua', name = 'test.lua', extension = 'lua' },
+ content = 'print("first")',
+ lines = '15-17' -- Dash format
+ }
+ local selection2 = {
+ file = { path = '/tmp/test.lua', name = 'test.lua', extension = 'lua' },
+ content = 'print("second")',
+ lines = '2, 4' -- Comma format
+ }
+ local selection3 = {
+ file = { path = '/tmp/test.lua', name = 'test.lua', extension = 'lua' },
+ content = 'print("third")',
+ lines = '20' -- Single line
+ }
+
+ ChatContext.add_selection(selection1)
+ ChatContext.add_selection(selection2)
+ ChatContext.add_selection(selection3)
+
+ -- Mock the BaseContext.get_diagnostics to capture the range parameter
+ local BaseContext = require('opencode.context.base_context')
+ local captured_range = nil
+ local original_get_diagnostics = BaseContext.get_diagnostics
+ BaseContext.get_diagnostics = function(buf, context_config, range)
+ captured_range = range
+ -- Return mock diagnostics for all ranges
+ if range and type(range) == 'table' and range[1] then
+ local result = {}
+ for i, r in ipairs(range) do
+ table.insert(result, {
+ lnum = r.start_line,
+ col = 0,
+ message = 'Mock diagnostic',
+ severity = 1
+ })
+ end
+ return result
+ end
+ return {}
+ end
+
+ -- Call get_diagnostics without an explicit range
+ local result = ChatContext.get_diagnostics(1, nil, nil)
+
+ -- Verify that a single range list was passed containing all selections
+ assert.is_not_nil(captured_range)
+ assert.equal('table', type(captured_range))
+ assert.equal(3, #captured_range) -- Should have three ranges in the list
+
+ -- Check ranges for different line formats
+ assert.equal(14, captured_range[1].start_line) -- 15 - 1 (0-based)
+ assert.equal(16, captured_range[1].end_line) -- 17 - 1 (0-based)
+
+ assert.equal(1, captured_range[2].start_line) -- 2 - 1 (0-based)
+ assert.equal(3, captured_range[2].end_line) -- 4 - 1 (0-based)
+
+ assert.equal(19, captured_range[3].start_line) -- 20 - 1 (0-based, single line)
+ assert.equal(19, captured_range[3].end_line) -- 20 - 1 (0-based, single line)
+
+ -- Verify that all diagnostics from all ranges are combined in the result
+ assert.equal(3, #result)
+
+ -- Restore original function
+ BaseContext.get_diagnostics = original_get_diagnostics
end)
end)
diff --git a/tests/unit/keymap_spec.lua b/tests/unit/keymap_spec.lua
index d0b8796c..f340a363 100644
--- a/tests/unit/keymap_spec.lua
+++ b/tests/unit/keymap_spec.lua
@@ -1,7 +1,3 @@
--- tests/unit/keymap_spec.lua
--- Tests for the keymap module
-
-local keymap = require('opencode.keymap')
local assert = require('luassert')
describe('opencode.keymap', function()
@@ -15,6 +11,10 @@ describe('opencode.keymap', function()
local original_keymap_set
local original_vim_cmd
+ -- Mock the API module to break circular dependency
+ local mock_api
+ local keymap
+
before_each(function()
set_keymaps = {}
cmd_calls = {}
@@ -34,12 +34,55 @@ describe('opencode.keymap', function()
vim.cmd = function(command)
table.insert(cmd_calls, command)
end
+
+ -- Mock the API module before requiring keymap
+ mock_api = {
+ open_input = function() end,
+ toggle = function() end,
+ submit_input_prompt = function() end,
+ permission_accept = function() end,
+ permission_accept_all = function() end,
+ permission_deny = function() end,
+ commands = {
+ open_input = { desc = 'Open input window' },
+ toggle = { desc = 'Toggle opencode windows' },
+ submit_input_prompt = { desc = 'Submit input prompt' },
+ },
+ }
+ package.loaded['opencode.api'] = mock_api
+
+ -- Mock the state module
+ local mock_state = {
+ current_permission = nil,
+ }
+ package.loaded['opencode.state'] = mock_state
+
+ -- Mock the config module
+ local mock_config = {
+ keymap = {
+ permission = {
+ accept = 'a',
+ accept_all = 'A',
+ deny = 'd',
+ },
+ },
+ }
+ package.loaded['opencode.config'] = mock_config
+
+ -- Now require the keymap module
+ keymap = require('opencode.keymap')
end)
after_each(function()
-- Restore original functions
vim.keymap.set = original_keymap_set
vim.cmd = original_vim_cmd
+
+ -- Clean up package loading
+ package.loaded['opencode.keymap'] = nil
+ package.loaded['opencode.api'] = nil
+ package.loaded['opencode.state'] = nil
+ package.loaded['opencode.config'] = nil
end)
describe('normalize_keymap', function()
diff --git a/tests/unit/util_spec.lua b/tests/unit/util_spec.lua
index bba42543..4d63af39 100644
--- a/tests/unit/util_spec.lua
+++ b/tests/unit/util_spec.lua
@@ -264,3 +264,101 @@ describe('util.format_time', function()
end)
end)
end)
+
+describe('util.parse_quick_context_args', function()
+ local function parse_and_verify(input, expected_prompt, context_checks)
+ local prompt, config = util.parse_quick_context_args(input)
+ assert.is_true(config.enabled)
+ assert.equals(expected_prompt, prompt)
+
+ if context_checks then
+ for context_type, checks in pairs(context_checks) do
+ if context_type == 'diagnostics' then
+ assert.is_true(config.diagnostics.enabled)
+ if checks.only_closest ~= nil then
+ assert.equals(checks.only_closest, config.diagnostics.only_closest)
+ end
+ for _, diag_type in ipairs({ 'warning', 'error', 'info' }) do
+ if checks[diag_type] ~= nil then
+ assert.equals(checks[diag_type], config.diagnostics[diag_type])
+ end
+ end
+ else
+ assert.is_true(config[context_type].enabled)
+ end
+ end
+ end
+ end
+
+ -- Test cases with expected results
+ local test_cases = {
+ -- Basic cases
+ { '', '', nil },
+ { nil, '', nil },
+
+ -- Single context types
+ { '#buffer', '', { buffer = {} } },
+ { 'add something #buffer', 'add something', { buffer = {} } },
+ { 'generate a conventional commit #git_diff', 'generate a conventional commit', { git_diff = {} } },
+ { 'explain this code #current_file', 'explain this code', { current_file = {} } },
+ { 'explain this code #file', 'explain this code', { current_file = {} } }, -- alias test
+ { 'refactor this #selection', 'refactor this', { selection = {} } },
+ { 'complete this line #cursor_data', 'complete this line', { cursor_data = {} } },
+ { 'complete this line #cursor', 'complete this line', { cursor_data = {} } }, -- alias test
+ { 'help with this task #agents', 'help with this task', { agents = {} } },
+
+ -- Diagnostic types
+ { 'fix these issues #warnings', 'fix these issues', { diagnostics = { warning = true, only_closest = true } } },
+ { 'debug this #errors', 'debug this', { diagnostics = { error = true, only_closest = true } } },
+ { 'review #info', 'review', { diagnostics = { info = true, only_closest = true } } },
+
+ -- Multiple contexts
+ {
+ 'generate a conventional commit #buffer #git_diff #warnings #errors',
+ 'generate a conventional commit',
+ { buffer = {}, git_diff = {}, diagnostics = { warning = true, error = true } },
+ },
+
+ -- Edge cases
+ {
+ 'generate #buffer a conventional #git_diff commit',
+ 'generate a conventional commit',
+ { buffer = {}, git_diff = {} },
+ },
+ {
+ 'Generate Code #BUFFER #Git_Diff #WaRnInGs',
+ 'Generate Code',
+ { buffer = {}, git_diff = {}, diagnostics = { warning = true } },
+ },
+ {
+ 'create function #buffer #invalid #git_diff #unknown',
+ 'create function #invalid #unknown',
+ { buffer = {}, git_diff = {} },
+ },
+ {
+ ' generate code #buffer #git_diff #warnings ',
+ 'generate code',
+ { buffer = {}, git_diff = {}, diagnostics = { warning = true } },
+ },
+ {
+ 'check code quality #warnings #errors #info',
+ 'check code quality',
+ { diagnostics = { warning = true, error = true, info = true } },
+ },
+ {
+ 'help me with this task #buffer #errors',
+ 'help me with this task',
+ { buffer = {}, diagnostics = { error = true } },
+ },
+ { '#buffer #git_diff #warnings', '', { buffer = {}, git_diff = {}, diagnostics = { warning = true } } },
+ }
+
+ for _, case in ipairs(test_cases) do
+ local input, expected_prompt, context_checks = case[1], case[2], case[3]
+ local test_name = input and input ~= '' and ('parses "' .. input .. '"') or 'handles empty/nil input'
+
+ it(test_name, function()
+ parse_and_verify(input, expected_prompt, context_checks)
+ end)
+ end
+end)