Skip to content

Commit 3427fbc

Browse files
authored
Merge branch 'main' into fix/markdown-double-render
2 parents 635fe3f + 7653234 commit 3427fbc

File tree

10 files changed

+156
-9
lines changed

10 files changed

+156
-9
lines changed

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ require('opencode').setup({
240240
debug = {
241241
enabled = false, -- Enable debug messages in the output window
242242
},
243+
prompt_guard = nil, -- Optional function that returns boolean to control when prompts can be sent (see Prompt Guard section)
243244
})
244245
```
245246

@@ -537,6 +538,31 @@ The plugin defines several highlight groups that can be customized to match your
537538
- `OpencodeInputLegend`: Highlight for input window legend (default: #CCCCCC background)
538539
- `OpencodeHint`: Highlight for hinting messages in input window and token info in output window footer (linked to `Comment`)
539540

541+
## 🛡️ Prompt Guard
542+
543+
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.
544+
545+
### Configuration
546+
547+
Set `prompt_guard` to a function that returns a boolean:
548+
549+
```lua
550+
require('opencode').setup({
551+
prompt_guard = function()
552+
-- Your custom logic here
553+
-- Return true to allow, false to deny
554+
return true
555+
end,
556+
})
557+
```
558+
559+
### Behavior
560+
561+
- **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.
562+
- **Before opening UI**: The guard is checked when opening the Opencode buffer for the first time. If denied, a WARN notification is shown and the UI is not opened.
563+
- **No parameters**: The guard function receives no parameters. Access vim state directly (e.g., `vim.fn.getcwd()`, `vim.bo.filetype`).
564+
- **Error handling**: If the guard function throws an error or returns a non-boolean value, the prompt is denied with an appropriate error message.
565+
540566
## 🔧 Setting up Opencode
541567

542568
If you're new to opencode:

lua/opencode/config.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,7 @@ M.defaults = {
167167
enabled = false,
168168
capture_streamed_events = false,
169169
},
170+
prompt_guard = nil,
170171
}
171172

172173
M.values = vim.deepcopy(M.defaults)

lua/opencode/core.lua

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,14 @@ function M.open(opts)
4848
local are_windows_closed = state.windows == nil
4949

5050
if are_windows_closed then
51+
-- Check if whether prompting will be allowed
52+
local context_module = require('opencode.context')
53+
local mentioned_files = context_module.context.mentioned_files or {}
54+
local allowed, err_msg = util.check_prompt_allowed(config.prompt_guard, mentioned_files)
55+
if not allowed then
56+
vim.notify(err_msg or 'Prompts will be denied by prompt_guard', vim.log.levels.WARN)
57+
end
58+
5159
state.windows = ui.create_windows()
5260
end
5361

@@ -81,6 +89,16 @@ end
8189
--- @param prompt string The message prompt to send.
8290
--- @param opts? SendMessageOpts
8391
function M.send_message(prompt, opts)
92+
-- Check if prompt is allowed
93+
local context_module = require('opencode.context')
94+
local mentioned_files = context_module.context.mentioned_files or {}
95+
local allowed, err_msg = util.check_prompt_allowed(config.prompt_guard, mentioned_files)
96+
97+
if not allowed then
98+
vim.notify(err_msg or 'Prompt denied by prompt_guard', vim.log.levels.ERROR)
99+
return
100+
end
101+
84102
opts = opts or {}
85103
opts.context = opts.context or config.context
86104
opts.model = opts.model or state.current_model

lua/opencode/types.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@
141141
---@field ui OpencodeUIConfig
142142
---@field context OpencodeContextConfig
143143
---@field debug OpencodeDebugConfig
144+
---@field prompt_guard? fun(mentioned_files: string[]): boolean
144145

145146
---@class MessagePartState
146147
---@field input TaskToolInput|BashToolInput|FileToolInput|TodoToolInput|GlobToolInput|GrepToolInput|WebFetchToolInput|ListToolInput Input data for the tool

lua/opencode/ui/highlight.lua

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ function M.setup()
2222
vim.api.nvim_set_hl(0, 'OpencodeContextualActions', { bg = '#90A4AE', fg = '#1976D2', bold = true, default = true })
2323
vim.api.nvim_set_hl(0, 'OpencodeInputLegend', { bg = '#757575', fg = '#424242', bold = false, default = true })
2424
vim.api.nvim_set_hl(0, 'OpencodeHint', { link = 'Comment', default = true })
25+
vim.api.nvim_set_hl(0, 'OpencodeGuardDenied', { fg = '#F44336', bold = true, default = true })
2526
else
2627
vim.api.nvim_set_hl(0, 'OpencodeBorder', { fg = '#616161', default = true })
2728
vim.api.nvim_set_hl(0, 'OpencodeBackground', { link = 'Normal', default = true })
@@ -41,6 +42,7 @@ function M.setup()
4142
vim.api.nvim_set_hl(0, 'OpencodeContextualActions', { bg = '#3b4261', fg = '#61AFEF', bold = true, default = true })
4243
vim.api.nvim_set_hl(0, 'OpencodeInputLegend', { bg = '#616161', fg = '#CCCCCC', bold = false, default = true })
4344
vim.api.nvim_set_hl(0, 'OpencodeHint', { link = 'Comment', default = true })
45+
vim.api.nvim_set_hl(0, 'OpencodeGuardDenied', { fg = '#EF5350', bold = true, default = true })
4446
end
4547
end
4648

lua/opencode/ui/icons.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ local presets = {
2525
-- statuses
2626
status_on = '🟢',
2727
status_off = '',
28+
guard_on = '🚫',
2829
-- borders and misc
2930
border = '',
3031
},
@@ -49,6 +50,7 @@ local presets = {
4950
-- statuses
5051
status_on = '',
5152
status_off = '',
53+
guard_on = '',
5254
-- borders and misc
5355
border = '',
5456
},
@@ -73,6 +75,7 @@ local presets = {
7375
-- statuses
7476
status_on = 'ON',
7577
status_off = 'OFF',
78+
guard_on = 'X',
7679
-- borders and misc
7780
border = '',
7881
},
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
local M = {}
2+
3+
local config = require('opencode.config')
4+
local util = require('opencode.util')
5+
local icons = require('opencode.ui.icons')
6+
local context = require('opencode.context')
7+
8+
---Get the current prompt guard status
9+
---@return boolean allowed
10+
---@return string|nil error_message
11+
function M.get_status()
12+
local mentioned_files = context.context.mentioned_files or {}
13+
return util.check_prompt_allowed(config.prompt_guard, mentioned_files)
14+
end
15+
16+
---Check if guard will deny prompts
17+
---@return boolean denied
18+
function M.is_denied()
19+
local allowed, _ = M.get_status()
20+
return not allowed
21+
end
22+
23+
---Get formatted indicator string with highlight (empty if allowed)
24+
---@return string formatted_indicator
25+
function M.get_formatted()
26+
if not M.is_denied() then
27+
-- Prompts are allowed - don't show anything
28+
return ''
29+
end
30+
31+
-- Prompts will be denied - show red indicator
32+
local icon = icons.get('guard_on')
33+
return string.format('%%#OpencodeGuardDenied#%s%%*', icon)
34+
end
35+
36+
return M

lua/opencode/ui/timer.lua

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ function Timer:start()
4848
end
4949
end
5050

51+
--- Start the timer and immediately execute the callback
52+
function Timer:start_and_tick()
53+
self:start()
54+
self.on_tick(unpack(self.args))
55+
end
56+
5157
function Timer:stop()
5258
if not self._uv_timer then
5359
return

lua/opencode/ui/topbar.lua

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ local M = {}
22

33
local state = require('opencode.state')
44
local config_file = require('opencode.config_file')
5+
local prompt_guard_indicator = require('opencode.ui.prompt_guard_indicator')
56

67
local LABELS = {
78
NEW_SESSION_TITLE = 'New session',
@@ -40,17 +41,40 @@ local function get_mode_highlight()
4041
end
4142
end
4243

43-
local function create_winbar_text(description, model_info, mode_info, win_width)
44-
local available_width = win_width - 2 -- 2 padding spaces
44+
local function create_winbar_text(description, model_info, mode_info, show_guard_indicator, win_width)
45+
-- Calculate how many visible characters we have
46+
-- Format: " [GUARD ]description padding model_info MODE "
47+
-- Where [GUARD ] is optional
4548

46-
-- If total length exceeds available width, truncate description
47-
if #description + 1 + #model_info + #mode_info + 1 > available_width then
48-
local space_for_desc = available_width - (#model_info + #mode_info + 1) - 4 -- -4 for "... "
49-
description = description:sub(1, space_for_desc) .. '... '
49+
local guard_info = ''
50+
local guard_visible_width = 0
51+
if show_guard_indicator then
52+
guard_info = prompt_guard_indicator.get_formatted()
53+
guard_visible_width = 2 -- icon + space
5054
end
5155

52-
local padding = string.rep(' ', available_width - #description - #model_info - #mode_info - 1)
53-
return string.format(' %s%s%s %s ', description, padding, model_info, get_mode_highlight() .. mode_info .. '%*')
56+
-- Calculate used width: leading space + guard + trailing space + model + mode
57+
local mode_info_str = get_mode_highlight() .. mode_info .. '%*'
58+
local mode_visible_width = #mode_info
59+
local model_visible_width = #model_info
60+
61+
-- Reserve space: 1 (padding) + guard_visible_width (with padding) + model + 1 (space before mode) + mode + 1 (padding)
62+
local reserved_width = 1 + guard_visible_width + model_visible_width + 1 + mode_visible_width + 1
63+
64+
-- Available width for description and padding
65+
local available_for_desc = win_width - reserved_width
66+
67+
-- Truncate description if needed
68+
if #description > available_for_desc then
69+
local space_for_desc = available_for_desc - 4 -- -4 for "... "
70+
description = description:sub(1, space_for_desc) .. '...'
71+
end
72+
73+
-- Calculate padding to right-align model and mode
74+
local padding_width = available_for_desc - #description
75+
local padding = string.rep(' ', math.max(0, padding_width))
76+
77+
return string.format(' %s %s%s%s %s ', guard_info, description, padding, model_info, mode_info_str)
5478
end
5579

5680
local function update_winbar_highlights(win_id)
@@ -96,8 +120,10 @@ function M.render()
96120
end
97121
-- topbar needs to at least have a value to make sure footer is positioned correctly
98122
vim.wo[win].winbar = ' '
123+
124+
local show_guard_indicator = prompt_guard_indicator.is_denied()
99125
vim.wo[win].winbar =
100-
create_winbar_text(get_session_desc(), format_model_info(), format_mode_info(), vim.api.nvim_win_get_width(win))
126+
create_winbar_text(get_session_desc(), format_model_info(), format_mode_info(), show_guard_indicator, vim.api.nvim_win_get_width(win))
101127

102128
update_winbar_highlights(win)
103129
end)
@@ -111,12 +137,14 @@ function M.setup()
111137
state.subscribe('current_mode', on_change)
112138
state.subscribe('current_model', on_change)
113139
state.subscribe('active_session', on_change)
140+
state.subscribe('is_opencode_focused', on_change)
114141
M.render()
115142
end
116143

117144
function M.close()
118145
state.unsubscribe('current_mode', on_change)
119146
state.unsubscribe('current_model', on_change)
120147
state.unsubscribe('active_session', on_change)
148+
state.unsubscribe('is_opencode_focused', on_change)
121149
end
122150
return M

lua/opencode/util.lua

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -385,6 +385,32 @@ function M.get_markdown_filetype(filename)
385385
end
386386

387387
return vim.fn.fnamemodify(filename, ':e')
388+
--- Check if prompt is allowed via guard callback
389+
--- @param guard_callback? function
390+
--- @param mentioned_files? string[] List of mentioned files in the context
391+
--- @return boolean allowed
392+
--- @return string|nil error_message
393+
function M.check_prompt_allowed(guard_callback, mentioned_files)
394+
if not guard_callback then
395+
return true, nil -- No guard = always allowed
396+
end
397+
398+
if not type(guard_callback) == 'function' then
399+
return false, 'prompt_guard must be a function'
400+
end
401+
402+
mentioned_files = mentioned_files or {}
403+
local success, result = pcall(guard_callback, mentioned_files)
404+
405+
if not success then
406+
return false, 'prompt_guard error: ' .. tostring(result)
407+
end
408+
409+
if type(result) ~= 'boolean' then
410+
return false, 'prompt_guard must return a boolean'
411+
end
412+
413+
return result, nil
388414
end
389415

390416
return M

0 commit comments

Comments
 (0)