Skip to content

Commit 3eec941

Browse files
committed
feat: optimize SEARCH/REPLACE quick chat for concise context and output rules
- Refactor quick chat SEARCH/REPLACE message setup for stricter Aider-style output - Only emit SEARCH/REPLACE blocks, with clear output and matching rules - Remove buffer context from quick chat, improving token efficiency, can be added with #buffer - Centralize and clarify context-specific guidance and dynamic rule composition
1 parent c69581e commit 3eec941

File tree

3 files changed

+66
-136
lines changed

3 files changed

+66
-136
lines changed

lua/opencode/context.lua

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@
55
local util = require('opencode.util')
66
local config = require('opencode.config')
77
local state = require('opencode.state')
8-
local Promise = require('opencode.promise')
98

10-
-- Import extracted modules
119
local ContextInstance = require('opencode.context.base')
1210
local json_formatter = require('opencode.context.json_formatter')
1311
local plain_text_formatter = require('opencode.context.plain_text_formatter')
@@ -136,13 +134,6 @@ function M.get_current_selection()
136134
return global_context:get_current_selection()
137135
end
138136

139-
--- Formats context as plain text for LLM consumption
140-
--- Outputs human-readable text instead of JSON message parts
141-
--- Alias: format_message_quick_chat
142-
---@param prompt string The user's instruction/prompt
143-
---@param context_instance ContextInstance Context instance to use
144-
---@param opts? { range?: { start: integer, stop: integer }, buf?: integer }
145-
---@return table result { text: string, parts: OpencodeMessagePart[] }
146137
M.format_message_plain_text = plain_text_formatter.format_message
147138

148139
--- Formats a prompt and context into message with parts for the opencode API
@@ -185,12 +176,6 @@ function M.format_message(prompt, opts)
185176
return parts
186177
end
187178

188-
--- Formats a prompt and context into plain text message for quick chat
189-
--- Alias for format_message_plain_text - used for ephemeral sessions
190-
---@param prompt string
191-
---@param context_instance ContextInstance Context instance to use
192-
---@param opts? { range?: { start: integer, stop: integer }, buf?: integer }
193-
---@return table result { text: string, parts: OpencodeMessagePart[] }
194179
M.format_message_quick_chat = plain_text_formatter.format_message
195180

196181
---@param text string

lua/opencode/context/plain_text_formatter.lua

Lines changed: 30 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,26 @@ function M.format_buffer(buf, lang)
105105
return string.format('FILE: %s\n\n```%s\n%s\n```', rel_path, lang, content)
106106
end
107107

108+
---@param buf integer
109+
---@param lang string
110+
---@param rel_path string
111+
---@param range {start: integer, stop: integer}
112+
---@return string
113+
function M.format_range(buf, lang, rel_path, range)
114+
local start_line = math.max(1, range.start)
115+
local end_line = range.stop
116+
local range_lines = vim.api.nvim_buf_get_lines(buf, start_line - 1, end_line, false)
117+
local range_text = table.concat(range_lines, '\n')
118+
119+
local parts = {
120+
string.format('Selected range from %s (lines %d-%d):', rel_path, start_line, end_line),
121+
'```' .. lang,
122+
range_text,
123+
'```',
124+
}
125+
return table.concat(parts, '\n')
126+
end
127+
108128
--- Formats context as plain text for LLM consumption (used by quick chat)
109129
--- Unlike format_message_quick_chat, this outputs human-readable text instead of JSON
110130
---@param prompt string The user's instruction/prompt
@@ -113,7 +133,9 @@ end
113133
---@return table result { text: string, parts: OpencodeMessagePart[] }
114134
M.format_message = Promise.async(function(prompt, context_instance, opts)
115135
opts = opts or {}
116-
local buf = opts.buf or context_instance:get_current_buf() or vim.api.nvim_get_current_buf()
136+
local buf = vim.api.nvim_get_current_buf()
137+
local win = vim.api.nvim_get_current_win()
138+
117139
local range = opts.range
118140

119141
local file_name = vim.api.nvim_buf_get_name(buf)
@@ -122,24 +144,17 @@ M.format_message = Promise.async(function(prompt, context_instance, opts)
122144

123145
local text_parts = {}
124146

125-
-- Add file/buffer content
126-
if context_instance:is_context_enabled('buffer') then
147+
if context_instance:is_context_enabled('selection') then
127148
if range and range.start and range.stop then
128-
local start_line = math.max(1, range.start)
129-
local end_line = range.stop
130-
local range_lines = vim.api.nvim_buf_get_lines(buf, start_line - 1, end_line, false)
131-
local range_text = table.concat(range_lines, '\n')
132-
133-
table.insert(text_parts, string.format('FILE: %s (lines %d-%d)', rel_path, start_line, end_line))
134149
table.insert(text_parts, '')
135-
table.insert(text_parts, '```' .. lang)
136-
table.insert(text_parts, range_text)
137-
table.insert(text_parts, '```')
138-
else
139-
table.insert(text_parts, M.format_buffer(buf, lang))
150+
table.insert(text_parts, M.format_range(buf, lang, rel_path, range))
140151
end
141152
end
142153

154+
if context_instance:is_context_enabled('buffer') then
155+
table.insert(text_parts, M.format_buffer(buf, lang))
156+
end
157+
143158
for _, sel in ipairs(context_instance:get_selections() or {}) do
144159
table.insert(text_parts, '')
145160
table.insert(text_parts, M.format_selection(sel))
@@ -159,8 +174,7 @@ M.format_message = Promise.async(function(prompt, context_instance, opts)
159174
end
160175

161176
if context_instance:is_context_enabled('cursor_data') then
162-
local current_buf, current_win = context_instance:get_current_buf()
163-
local cursor_data = context_instance:get_current_cursor_data(current_buf or buf, current_win or 0)
177+
local cursor_data = context_instance:get_current_cursor_data(buf, win)
164178
if cursor_data then
165179
table.insert(text_parts, '')
166180
table.insert(text_parts, M.format_cursor_data(cursor_data, lang))

lua/opencode/quick_chat.lua

Lines changed: 36 additions & 105 deletions
Original file line numberDiff line numberDiff line change
@@ -141,11 +141,13 @@ local on_done = Promise.async(function(active_session)
141141
cleanup_session(running_session, active_session.id, 'Failed to update file with quick chat response')
142142
end
143143

144-
--@TODO: enable session deletion after testing
145-
-- Always delete ephemeral session
146-
-- state.api_client:delete_session(session_obj.id):catch(function(err)
147-
-- vim.notify('Error deleting ephemeral session: ' .. vim.inspect(err), vim.log.levels.WARN)
148-
-- end)
144+
if config.debug.quick_chat and config.debug.quick_chat.keep_session then
145+
return
146+
end
147+
148+
state.api_client:delete_session(running_session.id):catch(function(err)
149+
vim.notify('Error deleting ephemeral session: ' .. vim.inspect(err), vim.log.levels.WARN)
150+
end)
149151
end)
150152

151153
---@param message string|nil The message to validate
@@ -167,135 +169,66 @@ local function validate_quick_chat_prerequisites(message)
167169
end
168170

169171
--- Creates context configuration for quick chat
172+
--- Optimized for minimal token usage while providing essential context
170173
---@param has_range boolean Whether a range is specified
171174
---@return OpencodeContextConfig context_opts
172175
local function create_context_config(has_range)
173176
return {
174177
enabled = true,
175-
current_file = { enabled = false },
176-
cursor_data = { enabled = not has_range },
177-
selection = { enabled = has_range },
178+
current_file = { enabled = false }, -- Disable full file content
179+
cursor_data = { enabled = not has_range, context_lines = 10 }, -- Only cursor position when no selection
180+
selection = { enabled = has_range }, -- Only selected text when range provided
178181
diagnostics = {
179182
enabled = true,
180183
error = true,
181184
warning = true,
182185
info = false,
183-
only_closest = has_range,
186+
only_closest = true, -- Only closest diagnostics, not all file diagnostics
184187
},
185-
agents = { enabled = false },
186-
buffer = { enabled = true },
187-
git_diff = { enabled = false },
188+
agents = { enabled = false }, -- No agent context needed
189+
buffer = { enabled = false }, -- Disable full buffer content for token efficiency
190+
git_diff = { enabled = false }, -- No git context needed
188191
}
189192
end
190193

191194
--- Generates instructions for the LLM to follow the SEARCH/REPLACE format
195+
--- This is inspired from Aider Chat approach
192196
---@param context_instance ContextInstance Context instance
193197
---@return string[] instructions Array of instruction lines
194-
local function generate_search_replace_instructions(context_instance)
198+
local generate_search_replace_instructions = Promise.async(function(context_instance)
195199
local base_instructions = {
196-
'# ROLE',
197-
'You are a precise code editing assistant. Your task is to modify code based on user instructions.',
198-
'',
199-
'# OUTPUT FORMAT',
200-
'You MUST output ONLY in SEARCH/REPLACE blocks. No explanations, no markdown, no additional text.',
201-
'',
200+
'Output ONLY SEARCH/REPLACE blocks, no explanations:',
202201
'<<<<<<< SEARCH',
203-
'[exact lines from the original code]',
202+
'[exact original code]',
204203
'=======',
205-
'[modified version of those lines]',
204+
'[modified code]',
206205
'>>>>>>> REPLACE',
207206
'',
208-
'# CRITICAL RULES',
209-
'1. **Exact matching**: Copy SEARCH content EXACTLY character-for-character from the provided code',
210-
'2. **Context lines**: Include 1-3 unchanged surrounding lines in SEARCH for unique identification',
211-
'3. **Indentation**: Preserve the exact indentation from the original code',
212-
'4. **Multiple changes**: Use separate SEARCH/REPLACE blocks for each distinct change',
213-
'5. **No explanations**: Output ONLY the SEARCH/REPLACE blocks, nothing else',
214-
'',
207+
'Rules: Copy SEARCH content exactly. Include 1-3 context lines for unique matching. Use empty SEARCH to insert at cursor. Output multiple blocks only if needed for more complex operations.',
215208
}
216209

217-
-- Add context-specific guidance
218210
local context_guidance = {}
219211

220-
if context_instance:has('diagnostics') then
221-
table.insert(context_guidance, '**DIAGNOSTICS context**: Use error/warning information to guide your fixes')
212+
if context_instance:has('diagnostics'):await() then
213+
table.insert(context_guidance, 'Fix errors/warnings (if asked)')
222214
end
223215

224-
if context_instance:has('selection') then
225-
table.insert(context_guidance, '**SELECTION context**: Only modify code within the selected range')
216+
if context_instance:has('selection'):await() then
217+
table.insert(context_guidance, 'Modify only selected range')
226218
elseif context_instance:has('cursor_data') then
227-
table.insert(context_guidance, '**CURSOR context**: Focus modifications near the cursor position')
219+
table.insert(context_guidance, 'Modify only near cursor')
228220
end
229221

230-
if context_instance:has('git_diff') then
231-
table.insert(context_guidance, '**GIT_DIFF context**: For reference only - never copy git diff syntax into SEARCH')
222+
if context_instance:has('git_diff'):await() then
223+
table.insert(context_guidance, "ONLY Reference git diff (don't copy syntax)")
232224
end
233225

234226
if #context_guidance > 0 then
235-
table.insert(base_instructions, '# CONTEXT USAGE')
236-
for _, guidance in ipairs(context_guidance) do
237-
table.insert(base_instructions, '- ' .. guidance)
238-
end
239-
table.insert(base_instructions, '')
240-
end
241-
242-
-- Add practical examples
243-
local examples = {
244-
'# EXAMPLES',
245-
'',
246-
'**Modify a return value:**',
247-
'<<<<<<< SEARCH',
248-
'function calculate()',
249-
' local result = x + y',
250-
' return result * 2',
251-
'end',
252-
'=======',
253-
'function calculate()',
254-
' local result = x + y',
255-
' return result * 3 -- Changed multiplier',
256-
'end',
257-
'>>>>>>> REPLACE',
258-
'',
259-
'**Insert a new line:**',
260-
'<<<<<<< SEARCH',
261-
'local config = {',
262-
' timeout = 5000,',
263-
'}',
264-
'=======',
265-
'local config = {',
266-
' timeout = 5000,',
267-
' retry_count = 3,',
268-
'}',
269-
'>>>>>>> REPLACE',
270-
'',
271-
'**Remove a line:**',
272-
'<<<<<<< SEARCH',
273-
'local debug_mode = true',
274-
'local verbose = true',
275-
'local silent = false',
276-
'=======',
277-
'local debug_mode = true',
278-
'local silent = false',
279-
'>>>>>>> REPLACE',
280-
'',
281-
'**Insert new code at cursor (empty SEARCH):**',
282-
'When the cursor is on an empty line or you need to insert without replacing, use an empty SEARCH section:',
283-
'<<<<<<< SEARCH',
284-
'=======',
285-
'local new_variable = "inserted at cursor"',
286-
'>>>>>>> REPLACE',
287-
'',
288-
'# FINAL REMINDER',
289-
'Output ONLY the SEARCH/REPLACE blocks. The SEARCH section must match the original code exactly.',
290-
'Use an empty SEARCH section to insert new code at the cursor position.',
291-
}
292-
293-
for _, line in ipairs(examples) do
294-
table.insert(base_instructions, line)
227+
table.insert(base_instructions, 'Context: ' .. table.concat(context_guidance, ', ') .. '.')
295228
end
296229

297230
return base_instructions
298-
end
231+
end)
299232

300233
--- Creates message parameters for quick chat
301234
---@param message string The user message
@@ -311,7 +244,7 @@ local create_message = Promise.async(function(message, buf, range, context_insta
311244
if quick_chat_config.instructions then
312245
instructions = quick_chat_config.instructions
313246
else
314-
instructions = generate_search_replace_instructions(context_instance)
247+
instructions = generate_search_replace_instructions(context_instance):await()
315248
end
316249

317250
local format_opts = { buf = buf }
@@ -365,11 +298,6 @@ M.quick_chat = Promise.async(function(message, options, range)
365298
local row, col = cursor_pos[1] - 1, cursor_pos[2] -- Convert to 0-indexed
366299
local spinner = CursorSpinner.new(buf, row, col)
367300

368-
-- Create context instance for diagnostics and other context
369-
local context_config = vim.tbl_deep_extend('force', create_context_config(range ~= nil), options.context_config or {})
370-
local context_instance = context.new_instance(context_config)
371-
372-
-- Check prompt guard with the current file
373301
local file_name = vim.api.nvim_buf_get_name(buf)
374302
local mentioned_files = file_name ~= '' and { file_name } or {}
375303
local allowed, err_msg = util.check_prompt_allowed(config.values.prompt_guard, mentioned_files)
@@ -385,8 +313,9 @@ M.quick_chat = Promise.async(function(message, options, range)
385313
return Promise.new():reject('Failed to create ephemeral session')
386314
end
387315

388-
--TODO only for debug
389-
state.active_session = quick_chat_session
316+
if config.debug.quick_chat and config.debug.quick_chat.set_active_session then
317+
state.active_session = quick_chat_session
318+
end
390319

391320
running_sessions[quick_chat_session.id] = {
392321
buf = buf,
@@ -396,6 +325,8 @@ M.quick_chat = Promise.async(function(message, options, range)
396325
timestamp = vim.uv.now(),
397326
}
398327

328+
local context_config = vim.tbl_deep_extend('force', create_context_config(range ~= nil), options.context_config or {})
329+
local context_instance = context.new_instance(context_config)
399330
local params = create_message(message, buf, range, context_instance, options):await()
400331

401332
local success, err = pcall(function()

0 commit comments

Comments
 (0)