Skip to content

Commit dae0a66

Browse files
committed
feat(context): add sent_at/sent_at_mtime file context state tracking
Track when current_file is sent in context to improve status updates, highlights, and delta computation logic. Refactor context read/write to access through get_context();
1 parent 280a97d commit dae0a66

File tree

13 files changed

+286
-115
lines changed

13 files changed

+286
-115
lines changed

lua/opencode/context.lua

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ local M = {}
1212
M.ChatContext = ChatContext
1313
M.QuickChatContext = QuickChatContext
1414

15+
-- Provide access to the context state
16+
function M.get_context()
17+
return ChatContext.context
18+
end
19+
1520
--- Formats context for main chat interface (new simplified API)
1621
---@param prompt string The user's instruction/prompt
1722
---@param context_config? OpencodeContextConfig Optional context config
@@ -78,8 +83,6 @@ function M.clear_selections()
7883
end
7984

8085
function M.add_file(file)
81-
ChatContext.context.mentioned_files = ChatContext.context.mentioned_files or {}
82-
8386
local is_file = vim.fn.filereadable(file) == 1
8487
local is_dir = vim.fn.isdirectory(file) == 1
8588
if not is_file and not is_dir then
@@ -137,8 +140,6 @@ function M.delta_context(opts)
137140
return ChatContext.delta_context(opts)
138141
end
139142

140-
M.context = ChatContext.context
141-
142143
---@param prompt string
143144
---@param opts? OpencodeContextConfig|nil
144145
---@return OpencodeMessagePart[]

lua/opencode/context/chat_context.lua

Lines changed: 87 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -283,20 +283,67 @@ function M.get_mentioned_subagents()
283283
return M.context.mentioned_subagents or {}
284284
end
285285

286+
---@param current_file table|nil
287+
---@return boolean, boolean -- should_update, is_different_file
288+
function M.should_update_current_file(current_file)
289+
if not M.context.current_file then
290+
return current_file ~= nil, false
291+
end
292+
293+
if not current_file then
294+
return false, false
295+
end
296+
297+
-- Different file name means update needed
298+
if M.context.current_file.name ~= current_file.name then
299+
return true, true
300+
end
301+
302+
local file_path = current_file.path
303+
if not file_path or vim.fn.filereadable(file_path) ~= 1 then
304+
return false, false
305+
end
306+
307+
local stat = vim.uv.fs_stat(file_path)
308+
if not (stat and stat.mtime and stat.mtime.sec) then
309+
return false, false
310+
end
311+
312+
local file_mtime_sec = stat.mtime.sec --[[@as number]]
313+
local last_sent_mtime = M.context.current_file.sent_at_mtime or 0
314+
return file_mtime_sec > last_sent_mtime, false
315+
end
316+
286317
-- Load function that populates the global context state
287318
-- This is the core loading logic that was originally in the main context module
288319
function M.load()
289320
local buf, win = base_context.get_current_buf()
290321

291-
if buf then
292-
local current_file = base_context.get_current_file(buf)
293-
local cursor_data = base_context.get_current_cursor_data(buf, win)
322+
if not buf or not win then
323+
return
324+
end
325+
326+
local current_file = base_context.get_current_file(buf)
327+
local cursor_data = base_context.get_current_cursor_data(buf, win)
328+
329+
local should_update_file, is_different_file = M.should_update_current_file(current_file)
330+
331+
if should_update_file then
332+
if is_different_file then
333+
M.context.selections = {}
334+
end
294335

295336
M.context.current_file = current_file
296-
M.context.cursor_data = cursor_data
297-
M.context.linter_errors = base_context.get_diagnostics(buf, nil, nil)
337+
if M.context.current_file then
338+
M.context.current_file.sent_at = nil
339+
M.context.current_file.sent_at_mtime = nil
340+
end
298341
end
299342

343+
M.context.cursor_data = cursor_data
344+
M.context.linter_errors = base_context.get_diagnostics(buf, nil, nil)
345+
346+
-- Handle current selection
300347
local current_selection = base_context.get_current_selection()
301348
if current_selection and M.context.current_file then
302349
local selection =
@@ -305,6 +352,18 @@ function M.load()
305352
end
306353
end
307354

355+
---@param current_file table
356+
local function set_file_sent_timestamps(current_file)
357+
if not current_file then
358+
return
359+
end
360+
current_file.sent_at = vim.uv.now()
361+
local stat = vim.uv.fs_stat(current_file.path)
362+
if stat and stat.mtime and stat.mtime.sec then
363+
current_file.sent_at_mtime = stat.mtime.sec
364+
end
365+
end
366+
308367
-- This function creates a context snapshot with delta logic against the last sent context
309368
function M.delta_context(opts)
310369
local config = require('opencode.config')
@@ -322,37 +381,31 @@ function M.delta_context(opts)
322381
end
323382

324383
local buf, win = base_context.get_current_buf()
325-
if not buf or not win then
384+
if not buf then
326385
return {}
327386
end
328387

329-
local ctx = {
330-
current_file = base_context.get_current_file(buf, opts),
331-
cursor_data = base_context.get_current_cursor_data(buf, win, opts),
332-
mentioned_files = M.context.mentioned_files or {},
333-
selections = M.context.selections or {},
334-
linter_errors = base_context.get_diagnostics(buf, opts, nil),
335-
mentioned_subagents = M.context.mentioned_subagents or {},
336-
}
388+
local ctx = vim.deepcopy(M.context)
337389

338-
-- Delta logic against last sent context
390+
if ctx.current_file and M.context.current_file then
391+
set_file_sent_timestamps(M.context.current_file)
392+
set_file_sent_timestamps(ctx.current_file)
393+
end
394+
395+
-- no need to send subagents again
339396
local last_context = state.last_sent_context
340397
if last_context then
341-
-- no need to send file context again
342-
if ctx.current_file and last_context.current_file and ctx.current_file.name == last_context.current_file.name then
343-
ctx.current_file = nil
344-
end
345-
346-
-- no need to send subagents again
347398
if
348399
ctx.mentioned_subagents
349400
and last_context.mentioned_subagents
350401
and vim.deep_equal(ctx.mentioned_subagents, last_context.mentioned_subagents)
351402
then
352403
ctx.mentioned_subagents = nil
404+
M.context.mentioned_subagents = nil
353405
end
354406
end
355407

408+
state.context_updated_at = vim.uv.now()
356409
return ctx
357410
end
358411

@@ -368,40 +421,42 @@ M.format_message = Promise.async(function(prompt, opts)
368421
local range = opts.range
369422
local parts = {}
370423

371-
-- Add mentioned files from global state (always process, even without buffer)
372424
for _, file_path in ipairs(M.context.mentioned_files or {}) do
373425
table.insert(parts, format_file_part(file_path, prompt))
374426
end
375427

376-
-- Add mentioned subagents from global state (always process, even without buffer)
377428
for _, agent in ipairs(M.context.mentioned_subagents or {}) do
378429
table.insert(parts, format_subagents_part(agent, prompt))
379430
end
380431

381-
if not buf or not win then
382-
-- Add the main prompt
432+
if not buf then
383433
table.insert(parts, { type = 'text', text = prompt })
384434
return { parts = parts }
385435
end
386436

387-
-- Add selections (both from range and global state)
437+
if M.context.current_file and not M.context.current_file.sent_at then
438+
table.insert(parts, format_file_part(M.context.current_file.path))
439+
set_file_sent_timestamps(M.context.current_file)
440+
end
441+
388442
if base_context.is_context_enabled('selection', context_config) then
389443
local selections = {}
390444

391-
-- Add range selection if specified
392445
if range and range.start and range.stop then
393446
local file = base_context.get_current_file(buf, context_config)
394447
if file then
395448
local selection = base_context.new_selection(
396449
file,
397-
table.concat(vim.api.nvim_buf_get_lines(buf, range.start - 1, range.stop, false), '\n'),
398-
string.format('%d-%d', range.start, range.stop)
450+
table.concat(
451+
vim.api.nvim_buf_get_lines(buf, math.floor(range.start) - 1, math.floor(range.stop), false),
452+
'\n'
453+
),
454+
string.format('%d-%d', math.floor(range.start), math.floor(range.stop))
399455
)
400456
table.insert(selections, selection)
401457
end
402458
end
403459

404-
-- Add current visual selection if available
405460
local current_selection = base_context.get_current_selection(context_config)
406461
if current_selection then
407462
local file = base_context.get_current_file(buf, context_config)
@@ -411,7 +466,6 @@ M.format_message = Promise.async(function(prompt, opts)
411466
end
412467
end
413468

414-
-- Add selections from global state
415469
for _, sel in ipairs(M.context.selections or {}) do
416470
table.insert(selections, sel)
417471
end
@@ -421,28 +475,19 @@ M.format_message = Promise.async(function(prompt, opts)
421475
end
422476
end
423477

424-
-- Add current file if enabled and not already mentioned
425-
local current_file = base_context.get_current_file(buf, context_config)
426-
if current_file and not vim.tbl_contains(M.context.mentioned_files or {}, current_file.path) then
427-
table.insert(parts, format_file_part(current_file.path))
428-
end
429-
430-
-- Add buffer content if enabled
431478
if base_context.is_context_enabled('buffer', context_config) then
432479
table.insert(parts, format_buffer_part(buf))
433480
end
434481

435-
-- Add diagnostics
436482
local diag_range = nil
437483
if range then
438-
diag_range = { start_line = range.start - 1, end_line = range.stop - 1 }
484+
diag_range = { start_line = math.floor(range.start) - 1, end_line = math.floor(range.stop) - 1 }
439485
end
440486
local diagnostics = base_context.get_diagnostics(buf, context_config, diag_range)
441487
if diagnostics and #diagnostics > 0 then
442-
table.insert(parts, format_diagnostics_part(diagnostics, nil)) -- No need to filter again
488+
table.insert(parts, format_diagnostics_part(diagnostics, diag_range))
443489
end
444490

445-
-- Add cursor data
446491
if base_context.is_context_enabled('cursor_data', context_config) then
447492
local cursor_data = base_context.get_current_cursor_data(buf, win, context_config)
448493
if cursor_data then
@@ -455,15 +500,13 @@ M.format_message = Promise.async(function(prompt, opts)
455500
end
456501
end
457502

458-
-- Add git diff
459503
if base_context.is_context_enabled('git_diff', context_config) then
460504
local diff_text = base_context.get_git_diff(context_config):await()
461505
if diff_text and diff_text ~= '' then
462506
table.insert(parts, format_git_diff_part(diff_text))
463507
end
464508
end
465509

466-
-- Add the main prompt
467510
table.insert(parts, { type = 'text', text = prompt })
468511

469512
return { parts = parts }

lua/opencode/core.lua

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ M.open = Promise.async(function(opts)
6969
local are_windows_closed = state.windows == nil
7070
if are_windows_closed then
7171
-- Check if whether prompting will be allowed
72-
local mentioned_files = context.context.mentioned_files or {}
72+
local mentioned_files = context.get_context().mentioned_files or {}
7373
local allowed, err_msg = util.check_prompt_allowed(config.prompt_guard, mentioned_files)
7474
if not allowed then
7575
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)
138138
return false
139139
end
140140

141-
local mentioned_files = context.context.mentioned_files or {}
141+
local mentioned_files = context.get_context().mentioned_files or {}
142142
local allowed, err_msg = util.check_prompt_allowed(config.prompt_guard, mentioned_files)
143143

144144
if not allowed then
@@ -222,7 +222,8 @@ end)
222222
---@param prompt string
223223
function M.after_run(prompt)
224224
context.unload_attachments()
225-
state.last_sent_context = vim.deepcopy(context.context)
225+
state.last_sent_context = vim.deepcopy(context.get_context())
226+
context.delta_context()
226227
require('opencode.history').write(prompt)
227228
M._abort_count = 0
228229
end

lua/opencode/quick_chat.lua

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ local function generate_raw_code_instructions(context_config)
289289
return {
290290
'I want you to act as a senior ' .. filetype .. ' developer. ' .. context_info,
291291
'I will ask you specific questions.',
292-
'I want you to ALWAYS return valid raw code ONLY ',
292+
'I want you to ALWAYS return valid RAW code ONLY ',
293293
'CRITICAL: NEVER add (codeblocks, explanations or any additional text). ',
294294
'Respect the current indentation and formatting of the existing code. ',
295295
"If you can't respond with code, respond with nothing.",

lua/opencode/types.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,7 @@
389389
---@field path string
390390
---@field name string
391391
---@field extension string
392+
---@field sent_at? number
392393

393394
---@class OpencodeMessagePartSourceText
394395
---@field start number

lua/opencode/ui/completion/context.lua

Lines changed: 24 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -44,27 +44,26 @@ local function format_diagnostics(diagnostics)
4444
return 'No diagnostics available.'
4545
end
4646

47-
local counts = {}
47+
local by_severity = { Error = {}, Warning = {}, Info = {}, Hint = {} }
4848
for _, diag in ipairs(diagnostics) do
49-
counts[diag.severity] = (counts[diag.severity] or 0) + 1
50-
end
51-
local parts = {}
52-
if counts[vim.diagnostic.severity.ERROR] then
53-
table.insert(parts, string.format('%d Error%s', counts[1], counts[1] > 1 and 's' or ''))
54-
end
55-
if counts[vim.diagnostic.severity.WARN] then
56-
table.insert(parts, string.format('%d Warning%s', counts[2], counts[2] > 1 and 's' or ''))
57-
end
58-
if counts[vim.diagnostic.severity.INFO] then
59-
table.insert(parts, string.format('%d Info%s', counts[3], counts[3] > 1 and 's' or ''))
49+
local severity = diag.severity
50+
local key = severity == 1 and 'Error' or severity == 2 and 'Warning' or severity == 3 and 'Info' or 'Hint'
51+
table.insert(by_severity[key], diag)
6052
end
6153

62-
return table.concat(parts, ', ')
54+
local summary = {}
55+
for key, items in pairs(by_severity) do
56+
if #items > 0 then
57+
table.insert(summary, string.format('%d %s%s', #items, key, #items > 1 and 's' or ''))
58+
end
59+
end
60+
return table.concat(summary, ', ')
6361
end
6462

6563
local function format_selection(selection)
66-
local lang = selection.file and selection.file.extension or ''
67-
return string.format('```%s\n%s\n```', lang, selection.content)
64+
local lang = (selection.file and selection.file.extension) or ''
65+
local content = selection.content or ''
66+
return string.format('```%s\n%s\n```', lang, content)
6867
end
6968

7069
---@param cursor_data? OpencodeContextCursorData
@@ -77,14 +76,15 @@ local function format_cursor_data(cursor_data)
7776
return 'No cursor data available.'
7877
end
7978

80-
local filetype = context.context.current_file and context.context.current_file.extension
81-
local parts = {
82-
'Line: ' .. (cursor_data.line or 'N/A'),
83-
(cursor_data.column or ''),
84-
string.format('```%s \n%s\n```', filetype, cursor_data.line_content or 'N/A'),
85-
}
86-
87-
return table.concat(parts, '\n')
79+
local filetype = context.get_context().current_file and context.get_context().current_file.extension or ''
80+
local content = cursor_data.line_content or ''
81+
return string.format(
82+
'Line: %s\nColumn: %s\n```%s\n%s\n```',
83+
tostring(cursor_data.line or 'N/A'),
84+
tostring(cursor_data.column or 'N/A'),
85+
filetype,
86+
content
87+
)
8888
end
8989

9090
---@param ctx OpencodeContext
@@ -238,7 +238,7 @@ local context_source = {
238238
return {}
239239
end
240240

241-
local ctx = context.delta_context()
241+
local ctx = context.get_context()
242242

243243
local items = {
244244
add_current_file_item(ctx),

0 commit comments

Comments
 (0)